| <!DOCTYPE html> |
| <html lang="ko"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>VDash - ํ๊ทธ์ํธ ๋ง๋ฒํ๊ต</title> |
| <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> |
| <style> |
| *{margin:0;padding:0;box-sizing:border-box}body{font-family:'Noto Sans KR',sans-serif;background:#fff;color:#1e293b;overflow-x:hidden} |
| #login-screen{min-height:100vh;background:linear-gradient(135deg,#1a1040,#2d1b69 40%,#1e40af);display:flex;align-items:center;justify-content:center} |
| .lc{background:#fff;border-radius:16px;padding:48px 40px;width:400px;box-shadow:0 25px 80px rgba(0,0,0,.35);text-align:center} |
| .ll{width:64px;height:64px;background:linear-gradient(135deg,#f59e0b,#d97706);border-radius:16px;display:flex;align-items:center;justify-content:center;font-size:32px;margin:0 auto 16px;box-shadow:0 8px 24px rgba(245,158,11,.3)} |
| .lc h1{font-size:24px;font-weight:900;margin-bottom:4px}.lc .lsub{font-size:11px;color:#94a3b8;margin-bottom:6px;font-family:'JetBrains Mono',monospace;letter-spacing:1px} |
| .ls{font-size:13px;color:#64748b;margin-bottom:28px}.lc label{display:block;font-size:12px;font-weight:600;color:#64748b;margin-bottom:6px;text-align:left} |
| .lc input{width:100%;padding:11px 14px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;font-family:inherit;outline:none;margin-bottom:20px;background:#f8fafc}.lc input:focus{border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.1)} |
| .lb2{width:100%;padding:12px;background:linear-gradient(135deg,#1e40af,#7c3aed);color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit} |
| .le{background:#fef2f2;color:#ef4444;padding:10px;border-radius:8px;font-size:13px;margin-bottom:20px;text-align:center;display:none}.le.show{display:block} |
| #dashboard{display:none} |
| .hd{background:linear-gradient(135deg,#1e40af,#3b82f6);padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between} |
| .hd-b{display:flex;align-items:center;gap:10px}.hd-l{width:30px;height:30px;background:rgba(255,255,255,.95);border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:16px} |
| .hd-t{font-size:15px;font-weight:700;color:#fff}.hd-s{font-size:9px;color:rgba(255,255,255,.6);font-family:'JetBrains Mono',monospace} |
| .hd-r{display:flex;align-items:center;gap:14px}.hd-c{font-family:'JetBrains Mono',monospace;font-size:11px;color:rgba(255,255,255,.8)} |
| .hd-o{padding:3px 10px;background:rgba(255,255,255,.15);border:1px solid rgba(255,255,255,.3);border-radius:5px;color:#fff;font-size:10px;font-weight:600;cursor:pointer} |
| .tabs{display:flex;background:#fff;border-bottom:2px solid #e2e8f0;overflow-x:auto;scrollbar-width:none}.tabs::-webkit-scrollbar{display:none} |
| .tab{padding:10px 14px;font-size:11px;font-weight:500;color:#94a3b8;cursor:pointer;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-2px;user-select:none;position:relative} |
| .tab:hover{color:#475569;background:#f8fafc}.tab.active{color:#3b82f6;border-bottom-color:#3b82f6;font-weight:700;background:#eff6ff} |
| .tab .bg{display:inline-block;min-width:16px;height:16px;background:#3b82f6;color:#fff;font-size:9px;font-weight:700;border-radius:8px;text-align:center;line-height:16px;padding:0 4px;margin-left:4px} |
| .tab .alert-dot{position:absolute;top:6px;right:6px;width:8px;height:8px;background:#ef4444;border-radius:50%;border:2px solid #fff} |
| .mgr{background:#f8fafc;border-bottom:1px solid #e2e8f0;padding:6px 20px;display:flex;align-items:center;gap:12px;font-size:11px;color:#475569;flex-wrap:wrap} |
| .mg{display:inline-block;padding:1px 5px;border-radius:3px;font-size:9px;font-weight:600;margin-right:2px}.mg-m{background:#eff6ff;color:#3b82f6}.mg-s{background:#f1f5f9;color:#64748b} |
| /* 3-column calendar layout */ |
| .main{display:flex;height:calc(100vh - 52px - 39px - 31px);overflow:hidden} |
| .col-cal{width:300px;min-width:300px;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;background:#f8fafc;overflow-y:auto} |
| .col-list{width:280px;min-width:280px;border-right:1px solid #e2e8f0;display:flex;flex-direction:column;overflow-y:auto;background:#fff} |
| .col-form{flex:1;display:flex;flex-direction:column;background:#fff;overflow-y:auto} |
| .left{width:380px;min-width:380px;border-right:2px solid #e2e8f0;display:flex;flex-direction:column;background:#f8fafc;overflow-y:auto} |
| .right{flex:1;display:flex;flex-direction:column;background:#fff;overflow-y:auto} |
| /* search */ |
| .search-box{padding:8px 12px;border-bottom:1px solid #e2e8f0;display:flex;gap:4px} |
| .search-box input{flex:1;padding:5px 8px;border:1px solid #e2e8f0;border-radius:4px;font-size:10px;outline:none;font-family:inherit}.search-box input:focus{border-color:#3b82f6} |
| .search-box select{padding:5px;border:1px solid #e2e8f0;border-radius:4px;font-size:10px;outline:none} |
| /* calendar */ |
| .ch2{display:flex;align-items:center;justify-content:space-between;padding:10px 10px 4px}.cn3{display:flex;align-items:center;gap:6px} |
| .cb2{width:24px;height:24px;background:#fff;border:1px solid #e2e8f0;border-radius:4px;color:#475569;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:11px}.cb2:hover{border-color:#3b82f6;color:#3b82f6} |
| .cl2{font-size:13px;font-weight:700;min-width:100px;text-align:center} |
| .ct2{padding:2px 6px;background:#eff6ff;border:1px solid #3b82f6;border-radius:3px;color:#3b82f6;font-size:8px;font-weight:700;cursor:pointer} |
| .cg2{padding:0 10px}.cw2{display:grid;grid-template-columns:repeat(7,1fr);margin-bottom:2px} |
| .cwd2{text-align:center;font-size:8px;font-weight:600;color:#94a3b8;padding:3px 0}.cwd2:first-child{color:#ef4444}.cwd2:last-child{color:#3b82f6} |
| .cd2{display:grid;grid-template-columns:repeat(7,1fr);gap:1px} |
| .dy{aspect-ratio:1;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:5px;cursor:pointer;font-size:10px;font-weight:500;gap:1px} |
| .dy:hover{background:#f1f5f9}.dy.emp{cursor:default}.dy.emp:hover{background:transparent}.dy.tod{background:#eff6ff;font-weight:700;color:#3b82f6} |
| .dy.sel{background:#3b82f6;color:#fff!important;font-weight:700}.dy.sun{color:#ef4444}.dy.sat{color:#3b82f6} |
| .dy .dots{display:flex;gap:2px;height:3px}.dy .dot{width:3px;height:3px;border-radius:50%;background:#f59e0b} |
| /* list items */ |
| .tl2{padding:8px 10px;flex:1;overflow-y:auto}.tlt2{font-size:9px;font-weight:700;color:#94a3b8;margin-bottom:4px;display:flex;align-items:center;gap:4px} |
| .tlt2 .cn4{background:#eff6ff;color:#3b82f6;font-size:8px;padding:1px 4px;border-radius:4px;font-family:'JetBrains Mono',monospace} |
| .tk2{background:#fff;border:1px solid #e2e8f0;border-radius:5px;padding:6px 8px;margin-bottom:3px;cursor:pointer;display:flex;align-items:flex-start;gap:6px;box-shadow:0 1px 2px rgba(0,0,0,.03)} |
| .tk2:hover{border-color:#3b82f6}.tk2.ac{border-color:#3b82f6;box-shadow:0 0 0 1px #3b82f6} |
| .tk2 .pb2{width:3px;min-height:18px;border-radius:2px;flex-shrink:0;align-self:stretch} |
| .ph2{background:#ef4444}.pm2{background:#f59e0b}.pl2{background:#10b981}.pp{background:#8b5cf6}.po{background:#f97316} |
| .tb2{flex:1;min-width:0}.tn2{font-size:10px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} |
| .tm2{font-size:8px;color:#94a3b8;margin-top:1px;display:flex;gap:3px;align-items:center;flex-wrap:wrap} |
| .dday{font-size:7px;font-weight:700;padding:1px 3px;border-radius:3px}.dd-r{background:#fef2f2;color:#ef4444}.dd-y{background:#fffbeb;color:#d97706}.dd-g{background:#f0fdf4;color:#059669} |
| /* form */ |
| .dh3{padding:10px 16px 0;display:flex;align-items:center;justify-content:space-between}.dt3{font-size:12px;font-weight:700} |
| .de3{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:#94a3b8;padding:20px;text-align:center}.de3 .ic{font-size:40px;opacity:.2} |
| .fm2{padding:10px 16px;display:flex;flex-direction:column;gap:8px} |
| .fr2{display:grid;grid-template-columns:1fr 1fr;gap:8px}.fg2{display:flex;flex-direction:column;gap:2px}.fg2.full{grid-column:1/-1} |
| .fl2{font-size:8px;font-weight:700;color:#94a3b8;letter-spacing:.3px;text-transform:uppercase} |
| .fi2,.fs2,.ft2{background:#f8fafc;border:1px solid #e2e8f0;border-radius:4px;padding:6px 8px;color:#1e293b;font-size:10px;font-family:inherit;outline:none;width:100%} |
| .fi2:focus,.fs2:focus,.ft2:focus{border-color:#3b82f6;box-shadow:0 0 0 2px rgba(59,130,246,.1)} |
| .fs2{appearance:none;cursor:pointer}.ft2{resize:vertical;min-height:44px} |
| .sg2{display:flex;gap:3px;flex-wrap:wrap}.sg2>div{flex:1;min-width:fit-content;padding:4px 6px;border:1px solid #e2e8f0;border-radius:3px;text-align:center;font-size:9px;font-weight:600;cursor:pointer;user-select:none;display:flex;align-items:center;justify-content:center;gap:2px}.sg2>div:hover{border-color:#94a3b8} |
| .s-a{background:#fef2f2;border-color:#ef4444!important;color:#ef4444}.s-b{background:#fffbeb;border-color:#f59e0b!important;color:#b45309} |
| .s-c{background:#ecfdf5;border-color:#10b981!important;color:#10b981}.s-d{background:rgba(139,92,246,.08);border-color:#8b5cf6!important;color:#8b5cf6} |
| .s-e{background:rgba(6,182,212,.08);border-color:#06b6d4!important;color:#0891b2}.s-f{background:#eff6ff;border-color:#3b82f6!important;color:#3b82f6} |
| .btn{padding:5px 12px;border-radius:4px;font-size:9px;font-weight:600;cursor:pointer;border:1px solid transparent;font-family:inherit;display:inline-flex;align-items:center;gap:3px} |
| .btn-p{background:#3b82f6;color:#fff}.btn-p:hover{background:#2563eb}.btn-p:disabled{opacity:.6} |
| .btn-d{background:transparent;border-color:#ef4444;color:#ef4444}.btn-d:hover{background:#fef2f2} |
| .btn-g{background:#fff;border-color:#e2e8f0;color:#475569}.btn-g:hover{border-color:#94a3b8} |
| .btn-n{background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff;border:none}.btn-n:hover{opacity:.9} |
| .fa2{display:flex;gap:4px;padding-top:6px;border-top:1px solid #e2e8f0;margin-top:4px}.sp2{flex:1} |
| .dv2{height:1px;background:#e2e8f0;margin:3px 10px} |
| .ov{position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:9999;display:flex;align-items:center;justify-content:center} |
| .dlg{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:20px;max-width:340px;width:90%;box-shadow:0 20px 60px rgba(0,0,0,.12)} |
| .dlg h3{font-size:14px;font-weight:700;margin-bottom:6px}.dlg p{font-size:12px;color:#475569;margin-bottom:14px} |
| .da2{display:flex;gap:6px;justify-content:flex-end} |
| .ld2{flex:1;display:flex;align-items:center;justify-content:center;color:#94a3b8;font-size:12px;gap:8px} |
| .spn2{width:16px;height:16px;border:2px solid #e2e8f0;border-top-color:#3b82f6;border-radius:50%;animation:sp2 .6s linear infinite} |
| @keyframes sp2{to{transform:rotate(360deg)}}@keyframes fd2{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.fade2{animation:fd2 .2s ease-out} |
| ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:2px} |
| .stabs{display:flex;gap:4px;flex-wrap:wrap}.stab{padding:5px 10px;font-size:10px;font-weight:600;color:#94a3b8;cursor:pointer;border-radius:5px;user-select:none}.stab:hover{background:#f1f5f9}.stab.active{background:#eff6ff;color:#3b82f6} |
| .dash{flex:1;overflow-y:auto;padding:14px 18px} |
| .kpi-r{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:6px;margin-bottom:12px} |
| .kpi{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:8px;text-align:center}.kpi .kv{font-size:18px;font-weight:900;font-family:'JetBrains Mono',monospace}.kpi .kl{font-size:7px;font-weight:600;color:#94a3b8;margin-top:1px} |
| .st{font-size:10px;font-weight:700;color:#64748b;margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px} |
| .pipe{display:flex;gap:3px;margin-bottom:6px;padding:0 10px}.pipe-s{flex:1;text-align:center;padding:4px 3px;border-radius:4px;font-size:8px;font-weight:700} |
| .ttg{padding:1px 3px;border-radius:3px;font-size:7px;font-weight:700} |
| .ttg-gov{background:#eff6ff;color:#3b82f6}.ttg-biz{background:#fff7ed;color:#ea580c}.ttg-dev{background:#ecfdf5;color:#059669}.ttg-pr{background:#faf5ff;color:#9333ea}.ttg-mgmt{background:#f0fdfa;color:#0d9488} |
| .prg{-webkit-appearance:none;width:100%;height:5px;border-radius:3px;background:#e2e8f0;outline:none}.prg::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;border-radius:50%;background:#3b82f6;cursor:pointer} |
| .urg{background:#fef2f2;border:1px solid #fecaca;border-radius:6px;padding:6px 10px;margin-bottom:8px}.urg-i{font-size:9px;color:#991b1b;margin-bottom:2px} |
| .pcard{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:5px;margin-bottom:12px} |
| .pcd{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:6px 8px}.pcd-n{font-size:10px;font-weight:700;margin-bottom:1px} |
| .pcd-r{font-size:7px;color:#94a3b8;margin-bottom:2px;display:flex;flex-wrap:wrap;gap:2px} |
| .pcd-role{font-size:7px;font-weight:600;padding:1px 3px;border-radius:2px}.pcd-s{font-size:8px;color:#475569;display:flex;gap:4px}.pcd-v{font-weight:700;font-family:'JetBrains Mono',monospace} |
| /* drops */ |
| .drop-input{display:flex;gap:6px;padding:10px 12px;background:#fff;border-bottom:1px solid #e2e8f0} |
| .drop-item{padding:6px 12px;border-bottom:1px solid #f1f5f9;display:flex;gap:8px;align-items:flex-start}.drop-item:hover{background:#f8fafc} |
| .drop-avatar{width:26px;height:26px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#8b5cf6);display:flex;align-items:center;justify-content:center;color:#fff;font-size:9px;font-weight:700;flex-shrink:0} |
| .drop-body{flex:1;min-width:0}.drop-name{font-size:9px;font-weight:700}.drop-time{font-size:7px;color:#94a3b8;margin-left:4px;font-family:'JetBrains Mono',monospace}.drop-text{font-size:10px;color:#475569;margin-top:1px;line-height:1.4} |
| .drop-actions{display:flex;gap:2px;margin-top:3px}.drop-react{padding:1px 5px;border:1px solid #e2e8f0;border-radius:10px;font-size:10px;cursor:pointer;background:#fff;user-select:none}.drop-react:hover{background:#f1f5f9}.drop-react.active{background:#eff6ff;border-color:#3b82f6} |
| .drop-del{font-size:8px;color:#94a3b8;cursor:pointer;opacity:0;transition:opacity .15s;margin-left:auto}.drop-item:hover .drop-del{opacity:1}.drop-del:hover{color:#ef4444} |
| /* news 4-col, pocket grid */ |
| .nc-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px} |
| .pkt-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px} |
| @media(max-width:1200px){.nc-grid{grid-template-columns:repeat(3,1fr)}.pkt-grid{grid-template-columns:repeat(2,1fr)}} |
| @media(max-width:900px){.nc-grid,.pkt-grid{grid-template-columns:repeat(2,1fr)}} |
| @media(max-width:600px){.nc-grid,.pkt-grid{grid-template-columns:1fr}} |
| .nc{background:#fff;border:1px solid #e2e8f0;border-radius:7px;padding:8px;box-shadow:0 1px 2px rgba(0,0,0,.03);display:flex;flex-direction:column;gap:3px}.nc:hover{border-color:#3b82f6} |
| .nc-t{font-size:10px;font-weight:700;line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} |
| .nc-tags{display:flex;gap:2px;flex-wrap:wrap}.nc-tag{font-size:6px;font-weight:600;padding:1px 4px;border-radius:2px} |
| .nc-rel{font-size:6px;font-weight:700;padding:1px 4px;border-radius:2px;white-space:nowrap} |
| .r-c{background:#fef2f2;color:#ef4444}.r-h{background:#fffbeb;color:#d97706}.r-m{background:#ecfdf5;color:#059669}.r-g{background:#f1f5f9;color:#94a3b8} |
| .nc-s{font-size:8px;color:#64748b;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} |
| .nc-m{display:flex;gap:4px;font-size:7px;color:#94a3b8;margin-top:auto}.nc-l{color:#3b82f6;text-decoration:none;font-weight:600}.nc-l:hover{text-decoration:underline} |
| .nf{flex:1;overflow-y:auto;padding:10px 14px}.nf-h{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.nf-t{font-size:12px;font-weight:700} |
| .nf-r{padding:3px 8px;background:#eff6ff;border:1px solid #3b82f6;border-radius:4px;color:#3b82f6;font-size:8px;font-weight:700;cursor:pointer} |
| .nf-fs{display:flex;gap:3px;flex-wrap:wrap;margin-bottom:6px}.nf-f{padding:2px 6px;border-radius:3px;font-size:7px;font-weight:600;cursor:pointer;border:1px solid #e2e8f0;background:#fff;color:#64748b}.nf-f.active{background:#3b82f6;color:#fff;border-color:#3b82f6} |
| /* chat */ |
| .chat-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden}.chat-msgs{flex:1;overflow-y:auto;padding:12px 16px} |
| .chat-msg{margin-bottom:10px;display:flex;gap:6px}.chat-msg.user{justify-content:flex-end} |
| .chat-msg .cb3{max-width:75%;padding:8px 12px;border-radius:10px;font-size:11px;line-height:1.6} |
| .chat-msg.user .cb3{background:#3b82f6;color:#fff;border-bottom-right-radius:3px} |
| .chat-msg.ai .cb3{background:#f1f5f9;color:#1e293b;border-bottom-left-radius:3px;font-size:11px;line-height:1.7} |
| .chat-input{display:flex;gap:6px;padding:10px 14px;border-top:1px solid #e2e8f0;background:#fff} |
| /* pocket card */ |
| .pkt{background:#fff;border:1px solid #e2e8f0;border-radius:6px;padding:8px;box-shadow:0 1px 2px rgba(0,0,0,.03);display:flex;flex-direction:column;gap:3px}.pkt:hover{border-color:#3b82f6} |
| .pkt-c{font-size:9px;color:#475569;line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.pkt-m{display:flex;gap:4px;font-size:7px;color:#94a3b8;align-items:center;margin-top:auto} |
| .pkt-l{color:#3b82f6;text-decoration:none;font-weight:600;font-size:8px;word-break:break-all;display:-webkit-box;-webkit-line-clamp:1;-webkit-box-orient:vertical;overflow:hidden}.pkt-l:hover{text-decoration:underline} |
| .pkt-tag{font-size:6px;font-weight:600;padding:1px 4px;border-radius:2px;background:#eff6ff;color:#3b82f6} |
| .rpt{background:#f8fafc;border:1px solid #e2e8f0;border-radius:7px;padding:12px;margin-bottom:8px}.rpt-t{font-size:11px;font-weight:700;margin-bottom:6px}.rpt-s{font-size:9px;color:#475569;line-height:1.7}.rpt-s b{color:#1e293b} |
| /* work request */ |
| .wr{background:#fff;border:1px solid #e2e8f0;border-radius:7px;padding:10px;margin-bottom:6px;box-shadow:0 1px 2px rgba(0,0,0,.03)}.wr:hover{border-color:#3b82f6} |
| .wr-h{display:flex;align-items:center;gap:4px;margin-bottom:4px;flex-wrap:wrap}.wr-from{font-size:9px;font-weight:700}.wr-arr{font-size:9px;color:#94a3b8}.wr-to{font-size:9px;font-weight:600;color:#3b82f6} |
| .wr-content{font-size:10px;color:#475569;line-height:1.4;padding:6px 8px;background:#f8fafc;border-radius:5px;margin-bottom:4px} |
| .wr-meta{display:flex;gap:6px;font-size:7px;color:#94a3b8;align-items:center;margin-bottom:4px}.wr-dl{font-weight:700;color:#ef4444} |
| .wr-status{font-size:7px;font-weight:700;padding:1px 5px;border-radius:3px}.ws-wait{background:#fffbeb;color:#d97706}.ws-done{background:#ecfdf5;color:#059669}.ws-urg{background:#fef2f2;color:#ef4444} |
| .wr-resp{margin-top:4px;border-top:1px solid #e2e8f0;padding-top:4px} |
| .wr-resp-item{display:flex;gap:4px;align-items:flex-start;margin-bottom:3px;padding:4px 6px;background:#f0fdf4;border-radius:4px} |
| .wr-resp-name{font-size:8px;font-weight:700;color:#059669;white-space:nowrap}.wr-resp-text{font-size:9px;color:#475569;flex:1}.wr-resp-time{font-size:7px;color:#94a3b8;white-space:nowrap;font-family:'JetBrains Mono',monospace} |
| .wr-reply{display:flex;gap:4px;margin-top:3px}.wr-badge{background:#ef4444;color:#fff;font-size:7px;font-weight:700;padding:1px 4px;border-radius:6px;margin-left:3px} |
| .wr-targets{display:flex;gap:3px;flex-wrap:wrap;margin-bottom:4px}.wr-tg{padding:2px 6px;border:1px solid #e2e8f0;border-radius:3px;font-size:8px;font-weight:500;cursor:pointer;user-select:none}.wr-tg.sel{background:#3b82f6;color:#fff;border-color:#3b82f6} |
| .wr-pri{display:flex;gap:4px;margin-bottom:4px}.wr-pri>div{padding:3px 8px;border:1px solid #e2e8f0;border-radius:3px;font-size:8px;font-weight:600;cursor:pointer}.wr-pri .sel-n{background:#eff6ff;border-color:#3b82f6;color:#3b82f6}.wr-pri .sel-u{background:#fef2f2;border-color:#ef4444;color:#ef4444} |
| /* dashboard search */ |
| .dash-search{display:flex;gap:6px;margin-bottom:10px}.dash-search input{flex:1;padding:6px 10px;border:1px solid #e2e8f0;border-radius:5px;font-size:10px;outline:none;font-family:inherit}.dash-search input:focus{border-color:#3b82f6} |
| /* mobile */ |
| @media(max-width:768px){ |
| .hd{padding:0 12px;height:44px}.hd-t{font-size:13px}.hd-s{display:none}.hd-c{display:none} |
| .tabs{padding:0 4px}.tab{padding:8px 10px;font-size:10px} |
| .main{flex-direction:column;height:auto;min-height:calc(100vh - 44px - 35px - 28px)} |
| .col-cal{width:100%;min-width:auto;border-right:none;border-bottom:1px solid #e2e8f0;max-height:280px} |
| .col-list{width:100%;min-width:auto;border-right:none;border-bottom:1px solid #e2e8f0;max-height:200px} |
| .col-form{min-height:300px} |
| .left{width:100%;min-width:auto;border-right:none;max-height:50vh} |
| .right{min-height:300px} |
| .nc-grid,.pkt-grid{grid-template-columns:1fr} |
| .kpi-r{grid-template-columns:repeat(3,1fr)} |
| .pcard{grid-template-columns:repeat(2,1fr)} |
| .chat-msg .cb3{max-width:90%} |
| } |
| </style> |
| </head> |
| <body> |
| <div id="login-screen"><div class="lc"><div class="ll">๐ง</div><h1>ํ๊ทธ์ํธ ๋ง๋ฒํ๊ต</h1><div class="lsub">VIDRAFT</div><div class="ls">๋น๋๋ํํธ ์
๋ฌด ๊ด๋ฆฌ ๋์๋ณด๋</div><div class="le" id="le">์ด๋ฆ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ํ๋ ธ์ต๋๋ค</div><label>์ด๋ฆ</label><input id="iu" type="text" placeholder="์ด๋ฆ์ ์
๋ ฅํ์ธ์" autofocus><label>๋น๋ฐ๋ฒํธ</label><input id="ip" type="password" placeholder="๋น๋ฐ๋ฒํธ"><button class="lb2" id="lb" onclick="doLogin()">์
์ฅํ๊ธฐ</button></div></div> |
| <div id="dashboard"><div id="vd-app"></div></div> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.23.9/babel.min.js"></script> |
| <script> |
| function getToken(){return localStorage.getItem('vd_token')}function setToken(t){localStorage.setItem('vd_token',t)}function getUser(){return localStorage.getItem('vd_user')||''}function setUser(u){localStorage.setItem('vd_user',u)}function clearToken(){localStorage.removeItem('vd_token');localStorage.removeItem('vd_user')}function authHeaders(){return{'Content-Type':'application/json','Authorization':'Bearer '+getToken()}} |
| async function doLogin(){var u=document.getElementById('iu').value.trim(),p=document.getElementById('ip').value.trim();if(!u||!p)return;document.getElementById('lb').disabled=true;document.getElementById('le').classList.remove('show');try{var r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});if(r.ok){var d=await r.json();setToken(d.token);setUser(d.user);showDashboard()}else document.getElementById('le').classList.add('show')}catch(e){document.getElementById('le').classList.add('show')}document.getElementById('lb').disabled=false} |
| document.getElementById('ip').addEventListener('keydown',function(e){if(e.key==='Enter')doLogin()});document.getElementById('iu').addEventListener('keydown',function(e){if(e.key==='Enter')document.getElementById('ip').focus()}); |
| function doLogout(){clearToken();document.getElementById('login-screen').style.display='flex';document.getElementById('dashboard').style.display='none'}function showDashboard(){document.getElementById('login-screen').style.display='none';document.getElementById('dashboard').style.display='block';bootReactApp()} |
| (async function(){if(getToken()){try{var r=await fetch('/api/tasks',{headers:authHeaders()});if(r.ok){showDashboard();return}}catch(e){}clearToken()}})(); |
| </script> |
| <script type="text/babel"> |
| const {useState,useEffect,useCallback,useMemo}=React; |
| async function apiCall(m,u,b){const o={method:m,headers:authHeaders()};if(b)o.body=JSON.stringify(b);const r=await fetch(u,o);if(r.status===401){doLogout();return null}return r.json()} |
| const api={getTasks:()=>apiCall('GET','/api/tasks'),saveTask:t=>apiCall('POST','/api/tasks',t),delTask:id=>apiCall('DELETE','/api/tasks/'+id), |
| getDrops:()=>apiCall('GET','/api/drops'),saveDrop:d=>apiCall('POST','/api/drops',d),delDrop:id=>apiCall('DELETE','/api/drops/'+id), |
| getPockets:()=>apiCall('GET','/api/pockets'),savePocket:p=>apiCall('POST','/api/pockets',p),delPocket:id=>apiCall('DELETE','/api/pockets/'+id), |
| getReqs:()=>apiCall('GET','/api/reqs'),saveReq:r=>apiCall('POST','/api/reqs',r),delReq:id=>apiCall('DELETE','/api/reqs/'+id)}; |
| |
| const TABS=[{id:'dashboard',label:'์ข
ํฉ ๋์๋ณด๋',icon:'๐'},{id:'gov',label:'์ ๋ถ ์ง์ ๊ณผ์ ',icon:'๐๏ธ'},{id:'biz',label:'์ ํด/์์
/ํฌ์',icon:'๐ผ'},{id:'dev',label:'๊ฐ๋ฐ/์ด์ ๋ฐ ํ๋ก์ ํธ',icon:'โ๏ธ'},{id:'pr',label:'PR',icon:'๐ข'},{id:'mgmt',label:'๊ฒฝ์๊ด๋ฆฌ',icon:'๐'},{id:'rnd',label:'R&D ํ๋ธ',icon:'๐ฌ'},{id:'wizard',label:'๋ง๋ฒ์ฌ',icon:'๐ง'}]; |
| const CAL_TABS=['gov','biz','dev','pr','mgmt']; |
| const MEMBERS=['ํ์์','๊นํ๋ด','์ฅ์ฌ์','๊น์๋ฏผ','์ ์ง๊ธฐ','์ด์ถฉํ','๊น๋ฏผ์','์ ์ ํ']; |
| const ADVISORS=['๊น๊ธฐํ','์ดํธ์ค','๊นํํ','์ต์ ์'];const ALL_M=[...MEMBERS,...ADVISORS]; |
| const MGR={gov:{m:'ํ์์',s:['์ฅ์ฌ์','๊นํ๋ด']},rnd:{m:'๊นํ๋ด',s:['ํ์์','์ฅ์ฌ์']},biz:{m:'๊น์๋ฏผ',s:['์ ์ง๊ธฐ','์ด์ถฉํ']},dev:{m:'์ฅ์ฌ์',s:['ํ์์','๊นํ๋ด']},pr:{m:'์ด์ถฉํ',s:['๊นํ๋ด']},mgmt:{m:'๊น๋ฏผ์',s:[]},monitor:{m:'์ ์ ํ',s:['์ฅ์ฌ์','๊นํ๋ด']}}; |
| const WD=['์ผ','์','ํ','์','๋ชฉ','๊ธ','ํ ']; |
| const TL={gov:'์ ๋ถ์ง์',biz:'์ ํด/์์
',dev:'๊ฐ๋ฐ/ํ๋ก์ ํธ',pr:'PR',mgmt:'๊ฒฝ์๊ด๋ฆฌ'}; |
| const TC={gov:'ttg-gov',biz:'ttg-biz',dev:'ttg-dev',pr:'ttg-pr',mgmt:'ttg-mgmt'}; |
| const TCLR={gov:'#3b82f6',biz:'#ea580c',dev:'#059669',pr:'#9333ea',mgmt:'#0d9488'}; |
| |
| const TCFG={ |
| gov:{label:'๊ณผ์ ',tl:'๊ณผ์ ๋ช
',fields:[{k:'department',l:'์ฃผ๊ด๋ถ์',t:'select',opts:['IITP','NIA','KISA','NIPA','๊ณผ๊ธฐ๋ถ','์ฐ์
๋ถ','์ค๊ธฐ๋ถ','๋ฌธ์ฒด๋ถ','๊ตญ๋ฐฉ๋ถ','ํ์๋ถ','๊ธฐํ']},{k:'priority',l:'์ค์๋',t:'pills',opts:[{v:'์',c:'s-a',i:'๐ด'},{v:'์ค',c:'s-b',i:'๐ก'},{v:'ํ',c:'s-c',i:'๐ข'}]},{k:'status',l:'์งํ',t:'pills',opts:[{v:'์กฐ์ฌ',c:'s-d',i:'๐'},{v:'์์ฑ',c:'s-b',i:'โ๏ธ'},{v:'์ ์ถ',c:'s-c',i:'๐ค'}]},{k:'category',l:'๋ถ๋ฅ',t:'select',opts:['์ ๊ท๊ณต๋ชจ','๊ณ์๊ณผ์ ','์ฌ์
ํ','์๋น์ฐฝ์
','๊ธฐํ']},{k:'budget',l:'๊ท๋ชจ',t:'text',ph:'5์ต์'},{k:'winRate',l:'์์ฃผํ๋ฅ (%)',t:'text',ph:'0~100'},{k:'link',l:'๋งํฌ',t:'text',ph:'https://...',full:1},{k:'memo',l:'๋ฉ๋ชจ',t:'area',full:1}],meta:t=>[t.department,t.status,t.budget].filter(Boolean),bar:t=>t.priority==='์'?'ph2':t.priority==='์ค'?'pm2':'pl2',pipe:['์กฐ์ฌ','์์ฑ','์ ์ถ'],pk:'status'}, |
| biz:{label:'๋',tl:'๊ฑด๋ช
',fields:[{k:'bizType',l:'๊ตฌ๋ถ',t:'pills',opts:[{v:'์ ํด',c:'s-f',i:'๐ค'},{v:'์์
',c:'s-e',i:'๐'},{v:'๋ง์ผํ
',c:'s-b',i:'๐ฃ'},{v:'ํฌ์์ ์น',c:'s-a',i:'๐ฐ'},{v:'IR',c:'s-d',i:'๐'}]},{k:'counterpart',l:'์๋๋ฐฉ',t:'text',ph:'๊ธฐ์
๋ช
/๋ด๋น์'},{k:'stage',l:'๋จ๊ณ',t:'pills',opts:[{v:'์ ์ด',c:'s-d',i:'๐'},{v:'๋ฏธํ
',c:'s-f',i:'๐ค'},{v:'ํ์',c:'s-b',i:'๐'},{v:'๊ณ์ฝ',c:'s-c',i:'โ
'}]},{k:'amount',l:'์์๊ธ์ก',t:'text',ph:'1์ต์'},{k:'keyPoint',l:'ํต์ฌ๋
ผ์',t:'area',full:1},{k:'followUp',l:'ํ์์กฐ์น',t:'area',full:1},{k:'link',l:'๋งํฌ',t:'text',ph:'https://...',full:1}],meta:t=>[t.bizType,t.counterpart,t.stage].filter(Boolean),bar:t=>t.stage==='๊ณ์ฝ'?'pl2':t.stage==='ํ์'?'pm2':'pp',pipe:['์ ์ด','๋ฏธํ
','ํ์','๊ณ์ฝ'],pk:'stage'}, |
| dev:{label:'ํ๋ก์ ํธ',tl:'ํ๋ก์ ํธ๋ช
',fields:[{k:'devType',l:'๊ตฌ๋ถ',t:'pills',opts:[{v:'์์ฃผ๊ณผ์ ',c:'s-f',i:'๐๏ธ'},{v:'์ธ์ฃผํ๋ก์ ํธ',c:'s-e',i:'๐ข'},{v:'์์ฒด๊ฐ๋ฐ',c:'s-d',i:'๐ง'},{v:'์ ์ง๋ณด์',c:'s-b',i:'๐'}]},{k:'client',l:'๋ฐ์ฃผ์ฒ',t:'text',ph:'์ ๋ถ๊ธฐ๊ด/๊ธฐ์
๋ช
'},{k:'contractAmt',l:'๊ณ์ฝ๊ธ์ก',t:'text',ph:'3์ต์'},{k:'stage',l:'์งํ๋จ๊ณ',t:'pills',opts:[{v:'์ฐฉ์',c:'s-d',i:'๐'},{v:'๊ฐ๋ฐ',c:'s-f',i:'โ๏ธ'},{v:'์ค๊ฐ์ ๊ฒ',c:'s-b',i:'๐'},{v:'๋ฉํ',c:'s-e',i:'๐ฆ'},{v:'์๋ฃ',c:'s-c',i:'โ
'}]},{k:'contractStart',l:'๊ณ์ฝ์์',t:'text',ph:'2026-04-01'},{k:'contractEnd',l:'๊ณ์ฝ์ข
๋ฃ',t:'text',ph:'2026-12-31'},{k:'progress',l:'์งํ๋ฅ ',t:'range'},{k:'platform',l:'ํ๋ซํผ',t:'select',opts:['HF Spaces','Supabase','Vercel','AWS','GitHub','๊ธฐํ']},{k:'link',l:'์ฐ์ถ๋ฌผ',t:'text',ph:'https://...',full:1},{k:'memo',l:'๋ฉ๋ชจ',t:'area',full:1}],meta:t=>[t.devType,t.client,t.stage,t.progress!=null?t.progress+'%':null].filter(Boolean),bar:t=>{const s=t.stage;return s==='์๋ฃ'?'pl2':s==='๋ฉํ'||s==='์ค๊ฐ์ ๊ฒ'?'pm2':s==='๊ฐ๋ฐ'?'po':'pp'},pipe:['์ฐฉ์','๊ฐ๋ฐ','์ค๊ฐ์ ๊ฒ','๋ฉํ','์๋ฃ'],pk:'stage'}, |
| pr:{label:'ํ๋ณด',tl:'์ ๋ชฉ',fields:[{k:'prType',l:'์ ํ',t:'pills',opts:[{v:'๋ณด๋์๋ฃ',c:'s-f',i:'๐ฐ'},{v:'๋ธ๋ก๊ทธ',c:'s-d',i:'โ๏ธ'},{v:'SNS',c:'s-e',i:'๐ฑ'},{v:'ํ์ฌ',c:'s-b',i:'๐ค'}]},{k:'media',l:'๋งค์ฒด',t:'text',ph:'๋งค์ฒด๋ช
'},{k:'prStatus',l:'์ํ',t:'pills',opts:[{v:'๊ธฐํ',c:'s-d',i:'๐ก'},{v:'์์ฑ',c:'s-b',i:'โ๏ธ'},{v:'๊ฒํ ',c:'s-e',i:'๐'},{v:'๊ฒ์',c:'s-c',i:'โ
'}]},{k:'reach',l:'๋๋ฌ',t:'text',ph:'์กฐํ์'},{k:'link',l:'URL',t:'text',ph:'https://...',full:1},{k:'memo',l:'๋ฉ๋ชจ',t:'area',full:1}],meta:t=>[t.prType,t.media,t.prStatus].filter(Boolean),bar:t=>t.prStatus==='๊ฒ์'?'pl2':t.prStatus==='๊ฒํ '?'pm2':'pp',pipe:['๊ธฐํ','์์ฑ','๊ฒํ ','๊ฒ์'],pk:'prStatus'}, |
| mgmt:{label:'์
๋ฌด',tl:'์
๋ฌด๋ช
',fields:[{k:'mgmtType',l:'๋ถ๋ฅ',t:'pills',opts:[{v:'ํ๊ณ/์ฌ๋ฌด',c:'s-f',i:'๐ณ'},{v:'๋ฒ๋ฌด/๊ณ์ฝ',c:'s-d',i:'โ๏ธ'},{v:'์ธ์ฌ/์ฑ์ฉ',c:'s-e',i:'๐ฅ'},{v:'๋๊ด',c:'s-a',i:'๐๏ธ'},{v:'์ด๋ฌด',c:'s-b',i:'๐ฆ'}]},{k:'target',l:'๋์/๊ฑฐ๋์ฒ',t:'text',ph:'๊ธฐ๊ด๋ช
, ๊ธฐ์
๋ช
, ๋ด๋น์'},{k:'purpose',l:'๋ชฉ์ /๋ด์ฉ',t:'text',ph:'์ ์, ๋ฏผ์, ํ์
, ๊ณ์ฝ ๋ฑ'},{k:'amount',l:'๊ธ์ก',t:'text',ph:'500๋ง์'},{k:'mgmtStatus',l:'์ํ',t:'pills',opts:[{v:'์์ ',c:'s-d',i:'๐
'},{v:'์ ์ด',c:'s-f',i:'๐'},{v:'์งํ',c:'s-b',i:'๐'},{v:'ํ์ ๋๊ธฐ',c:'s-e',i:'โณ'},{v:'์๋ฃ',c:'s-c',i:'โ
'}]},{k:'result',l:'๊ฒฐ๊ณผ/ํ์ ',t:'area',full:1},{k:'link',l:'์ฒจ๋ถ/๋งํฌ',t:'text',ph:'https://...',full:1},{k:'memo',l:'๋ฉ๋ชจ',t:'area',full:1}],meta:t=>[t.mgmtType,t.target,t.mgmtStatus].filter(Boolean),bar:t=>t.mgmtStatus==='์๋ฃ'?'pl2':t.mgmtStatus==='์งํ'||t.mgmtStatus==='ํ์ ๋๊ธฐ'?'pm2':t.mgmtStatus==='์ ์ด'?'po':'pp',pipe:['์์ ','์ ์ด','์งํ','ํ์ ๋๊ธฐ','์๋ฃ'],pk:'mgmtStatus'} |
| }; |
| |
| function gid(){return Date.now().toString(36)+Math.random().toString(36).substr(2,6)} |
| function emptyItem(d,tab){const b={id:'',tab:tab||'gov',title:'',deadline:d||'',assignee:''};const c=TCFG[tab];if(c)c.fields.forEach(f=>{if(!(f.k in b))b[f.k]=f.t==='range'?0:''});return b} |
| function fmtD(y,m,d){return y+'-'+String(m+1).padStart(2,'0')+'-'+String(d).padStart(2,'0')} |
| function dim(y,m){return new Date(y,m+1,0).getDate()}function fdow(y,m){return new Date(y,m,1).getDay()} |
| function dday(dl){if(!dl)return null;const d=Math.ceil((new Date(dl)-new Date().setHours(0,0,0,0))/86400000);if(d<0)return{t:'D+'+(-d),c:'dd-r'};if(d===0)return{t:'TODAY',c:'dd-r'};if(d<=3)return{t:'D-'+d,c:'dd-r'};if(d<=7)return{t:'D-'+d,c:'dd-y'};return{t:'D-'+d,c:'dd-g'}} |
| |
| function MgrBar({tabId}){const m=MGR[tabId];if(!m)return null;return<div className="mgr"><span style={{fontWeight:700,color:'#3b82f6',fontSize:10}}>๋ด๋น</span><span><span className="mg mg-m">์ </span><span style={{fontWeight:600}}>{m.m}</span></span>{m.s.length>0&&<span><span className="mg mg-s">๋ถ</span><span style={{color:'#94a3b8'}}>{m.s.join(', ')}</span></span>}</div>} |
| |
| function Calendar({year,month,tasks,selectedDate,onSelect,onPrev,onNext,onToday}){ |
| const dm_=dim(year,month),fw=fdow(year,month),now=new Date(),ts=fmtD(now.getFullYear(),now.getMonth(),now.getDate()); |
| const bd={};tasks.forEach(t=>{if(t.deadline)(bd[t.deadline]||(bd[t.deadline]=[])).push(t)});const cells=[]; |
| for(let i=0;i<fw;i++)cells.push(<div key={'e'+i} className="dy emp"/>); |
| for(let d=1;d<=dm_;d++){const ds=fmtD(year,month,d),dow=(fw+d-1)%7,c=['dy'];if(ds===ts)c.push('tod');if(ds===selectedDate)c.push('sel');if(dow===0)c.push('sun');if(dow===6)c.push('sat');const dt=bd[ds]||[]; |
| cells.push(<div key={d} className={c.join(' ')} onClick={()=>onSelect(ds)}><span>{d}</span><div className="dots">{dt.slice(0,3).map((t,i)=><span key={i} className="dot"/>)}</div></div>)} |
| return<div><div className="ch2"><div className="cn3"><button className="cb2" onClick={onPrev}>โ</button><span className="cl2">{year}๋
{month+1}์</span><button className="cb2" onClick={onNext}>โถ</button></div><button className="ct2" onClick={onToday}>์ค๋</button></div> |
| <div className="cg2"><div className="cw2">{WD.map(w=><div key={w} className="cwd2">{w}</div>)}</div><div className="cd2">{cells}</div></div></div>} |
| |
| function ItemList({items,selectedId,onSelect,tabId,showTabTag,search}){ |
| const cfg=TCFG[tabId]||TCFG.gov; |
| const filtered=search?items.filter(t=>(t.title||'').includes(search)||(t.assignee||'').includes(search)):items; |
| const sorted=[...filtered].sort((a,b)=>(a.deadline||'').localeCompare(b.deadline||'')); |
| return<div className="tl2"><div className="tlt2">๐ {cfg.label} <span className="cn4">{sorted.length}</span></div> |
| {sorted.length===0&&<div style={{color:'#94a3b8',fontSize:9,textAlign:'center',padding:10}}>ํญ๋ชฉ ์์</div>} |
| {sorted.map(t=>{const dd=dday(t.deadline);return<div key={t.id} className={'tk2'+(t.id===selectedId?' ac':'')} onClick={()=>onSelect(t.id)}> |
| <div className={'pb2 '+cfg.bar(t)}/><div className="tb2"><div className="tn2">{t.title||'(์ ๋ชฉ ์์)'}</div> |
| <div className="tm2">{showTabTag&&<span className={'ttg '+(TC[t.tab]||'')}>{TL[t.tab]||t.tab}</span>} |
| {dd&&<span className={'dday '+dd.c}>{dd.t}</span>} |
| <span>{t.deadline}</span>{t.assignee&&<span>โข {t.assignee}</span>}{cfg.meta(t).map((m,i)=><span key={i}>โข {m}</span>)}</div></div></div>})}</div>} |
| |
| function TabForm({item,isNew,onSave,onDelete,onNew,onCancel,saving,currentTab}){ |
| const cfg=TCFG[currentTab]||TCFG.gov;const[f,setF]=useState(item||emptyItem('',currentTab)); |
| const iid=item?item.id:(isNew?'__new__':null);useEffect(()=>{setF(item||emptyItem('',currentTab))},[iid]); |
| if(!item&&!isNew)return<div className="col-form"><div className="de3"><div className="ic">๐</div><div style={{fontSize:11,color:'#94a3b8'}}>ํญ๋ชฉ ์ ํ</div><button className="btn btn-n" onClick={onNew}>๏ผ ์ {cfg.label}</button></div></div>; |
| const u=(k,v)=>setF(p=>({...p,[k]:v}));const save=()=>{if(!f.title?.trim()){alert(cfg.tl+' ํ์');return}onSave({...f,tab:currentTab,id:f.id||gid()})}; |
| const rf=(fd)=>{if(fd.t==='text')return<input className="fi2" value={f[fd.k]||''} onChange={e=>u(fd.k,e.target.value)} placeholder={fd.ph||''}/>;if(fd.t==='area')return<textarea className="ft2" value={f[fd.k]||''} onChange={e=>u(fd.k,e.target.value)} placeholder={fd.ph||''}/>;if(fd.t==='select')return<select className="fs2" value={f[fd.k]||''} onChange={e=>u(fd.k,e.target.value)}><option value="">์ ํ</option>{fd.opts.map(o=><option key={o}>{o}</option>)}</select>;if(fd.t==='pills')return<div className="sg2">{fd.opts.map(o=><div key={o.v} className={f[fd.k]===o.v?o.c:''} onClick={()=>u(fd.k,o.v)}>{o.i} {o.v}</div>)}</div>;if(fd.t==='range')return<div style={{display:'flex',alignItems:'center',gap:6}}><input type="range" className="prg" min="0" max="100" value={f[fd.k]||0} onChange={e=>u(fd.k,+e.target.value)}/><span style={{fontSize:10,fontWeight:700,fontFamily:'JetBrains Mono',minWidth:28}}>{f[fd.k]||0}%</span></div>;return null}; |
| const pairs=[];let i=0;while(i<cfg.fields.length){const fd=cfg.fields[i];if(fd.full){pairs.push([fd]);i++}else if(i+1<cfg.fields.length&&!cfg.fields[i+1].full){pairs.push([fd,cfg.fields[i+1]]);i+=2}else{pairs.push([fd]);i++}} |
| return<div className="col-form fade2"><div className="dh3"><div className="dt3">{isNew?'๐ ์ '+cfg.label:'๐ ์์ธ'}</div><button className="btn btn-n" onClick={onNew}>๏ผ ์ {cfg.label}</button></div> |
| <div className="fm2"><div className="fr2"><div className="fg2"><span className="fl2">{cfg.tl}</span><input className="fi2" value={f.title||''} onChange={e=>u('title',e.target.value)} placeholder={cfg.tl}/></div> |
| <div className="fg2"><span className="fl2">๋ด๋น์</span><select className="fs2" value={f.assignee||''} onChange={e=>u('assignee',e.target.value)}><option value="">์ ํ</option>{ALL_M.map(m=><option key={m}>{m}</option>)}</select></div></div> |
| <div className="fr2"><div className="fg2"><span className="fl2">๋ง๊ฐ์ผ</span><input type="date" className="fi2" value={f.deadline||''} onChange={e=>u('deadline',e.target.value)}/></div><div className="fg2"/></div> |
| {pairs.map((row,ri)=>row.length===1&&row[0].full?<div key={ri} className="fg2 full"><span className="fl2">{row[0].l}</span>{rf(row[0])}</div>:<div key={ri} className="fr2">{row.map(fd=><div key={fd.k} className="fg2"><span className="fl2">{fd.l}</span>{rf(fd)}</div>)}</div>)} |
| <div className="fa2">{!isNew&&<button className="btn btn-d" onClick={()=>onDelete(f.id)}>๐๏ธ</button>}<div className="sp2"/><button className="btn btn-g" onClick={onCancel}>์ทจ์</button><button className="btn btn-p" onClick={save} disabled={saving}>{saving?'โณ':(isNew?'๐พ ๋ฑ๋ก':'๐พ ์ ์ฅ')}</button></div> |
| </div></div>} |
| |
| function PipeBar({items,config}){if(!config?.pipe)return null;const cn={};config.pipe.forEach(s=>cn[s]=0);items.forEach(t=>{const v=t[config.pk];if(v in cn)cn[v]++});const cl=['#8b5cf6','#3b82f6','#f59e0b','#10b981','#06b6d4'];return<div className="pipe">{config.pipe.map((s,i)=><div key={s} className="pipe-s" style={{background:cl[i%cl.length]+'18',color:cl[i%cl.length]}}>{s} {cn[s]}</div>)}</div>} |
| |
| |
| function CalTab({allTasks,tabId,saving,setSaving,setAllTasks}){ |
| const isDash=tabId==='dashboard';const cfg=TCFG[isDash?'gov':tabId]||TCFG.gov; |
| const items=isDash?allTasks.filter(t=>CAL_TABS.includes(t.tab||'gov')):allTasks.filter(t=>(t.tab||'gov')===tabId); |
| const now=new Date();const[year,setYear]=useState(now.getFullYear());const[month,setMonth]=useState(now.getMonth()); |
| const[selDate,setSelDate]=useState(null);const[selId,setSelId]=useState(null);const[isNew,setIsNew]=useState(false);const[cfm,setCfm]=useState(null);const[newTab,setNewTab]=useState('gov');const[search,setSearch]=useState(''); |
| const monthStartStr=fmtD(year,month,1); |
| const mI=items.filter(t=>t.deadline&&t.deadline>=monthStartStr); |
| const sel=selId?allTasks.find(t=>t.id===selId):null; |
| const hsd=d=>{setSelDate(d);setSelId(null);setIsNew(false);const dt=items.filter(t=>t.deadline===d);if(dt.length>0)setSelId(dt[0].id)}; |
| const pm=()=>{if(month===0){setMonth(11);setYear(y=>y-1)}else setMonth(m=>m-1);setSelDate(null);setSelId(null);setIsNew(false)}; |
| const nm=()=>{if(month===11){setMonth(0);setYear(y=>y+1)}else setMonth(m=>m+1);setSelDate(null);setSelId(null);setIsNew(false)}; |
| const gt=()=>{const n=new Date();setYear(n.getFullYear());setMonth(n.getMonth());setSelDate(fmtD(n.getFullYear(),n.getMonth(),n.getDate()))}; |
| const hs=async(data)=>{setSaving(true);const r=await api.saveTask(data);if(r)setAllTasks(r);setSelId(data.id);setIsNew(false);setSaving(false)}; |
| const hd=async()=>{setSaving(true);const r=await api.delTask(cfm);if(r)setAllTasks(r);setSelId(null);setIsNew(false);setCfm(null);setSaving(false)}; |
| const actualTab=isDash?(isNew?newTab:(sel?.tab||'gov')):tabId; |
| |
| return<><div className="main"> |
| <div className="col-cal"> |
| <Calendar year={year} month={month} tasks={items} selectedDate={selDate} onSelect={hsd} onPrev={pm} onNext={nm} onToday={gt}/> |
| {isDash&&<div className="pipe">{CAL_TABS.map(tid=><div key={tid} className="pipe-s" style={{background:(TCLR[tid]||'#94a3b8')+'18',color:TCLR[tid]}}>{TL[tid]} {mI.filter(t=>(t.tab||'gov')===tid).length}</div>)}</div>} |
| {!isDash&&<PipeBar items={mI} config={cfg}/>} |
| <div className="search-box"><input value={search} onChange={e=>setSearch(e.target.value)} placeholder="๐ ๊ฒ์ (์ ๋ชฉ/๋ด๋น์)"/></div> |
| </div> |
| <div className="col-list"> |
| <ItemList items={mI} selectedId={selId} onSelect={id=>{setSelId(id);setIsNew(false)}} tabId={isDash?(sel?.tab||'gov'):tabId} showTabTag={isDash} search={search}/> |
| </div> |
| {isDash&&isNew?<div className="col-form fade2"><div className="dh3"><div className="dt3">๐ ์ ํญ๋ชฉ</div></div><div className="fm2"><div className="fg2"><span className="fl2">ํญ ์ ํ</span><div className="sg2">{CAL_TABS.map(tid=><div key={tid} className={newTab===tid?'s-f':''} onClick={()=>setNewTab(tid)}>{TL[tid]}</div>)}</div></div></div> |
| <TabForm item={emptyItem(selDate||'',newTab)} isNew={true} onSave={hs} onDelete={()=>{}} onNew={()=>{}} onCancel={()=>{setSelId(null);setIsNew(false)}} saving={saving} currentTab={newTab}/></div> |
| :isDash&&sel?<TabForm item={sel} isNew={false} onSave={hs} onDelete={id=>setCfm(id)} onNew={()=>{setSelId(null);setIsNew(true)}} onCancel={()=>{setSelId(null);setIsNew(false)}} saving={saving} currentTab={sel.tab||'gov'}/> |
| :isDash?<div className="col-form"><div className="de3"><div className="ic">๐
</div><div style={{fontSize:11,color:'#94a3b8'}}>ํตํฉ ์บ๋ฆฐ๋</div><button className="btn btn-n" onClick={()=>{setSelId(null);setIsNew(true)}}>๏ผ ์ ํญ๋ชฉ</button></div></div> |
| :<TabForm item={isNew?emptyItem(selDate||'',tabId):sel} isNew={isNew} onSave={hs} onDelete={id=>setCfm(id)} onNew={()=>{setSelId(null);setIsNew(true)}} onCancel={()=>{setSelId(null);setIsNew(false)}} saving={saving} currentTab={tabId}/>} |
| </div>{cfm&&<div className="ov" onClick={()=>setCfm(null)}><div className="dlg fade2" onClick={e=>e.stopPropagation()}><h3>์ญ์ </h3><p>์ญ์ ํ์๊ฒ ์ต๋๊น?</p><div className="da2"><button className="btn btn-g" onClick={()=>setCfm(null)}>์ทจ์</button><button className="btn btn-d" onClick={hd}>๐๏ธ</button></div></div></div>}</>} |
| |
| function DailyDrop({drops,setDrops}){ |
| const[text,setText]=useState('');const[saving,setSaving]=useState(false); |
| const submit=async()=>{if(!text.trim())return;setSaving(true);const d={id:gid(),author:getUser(),text:text.trim(),date:new Date().toISOString(),reactions:{}};const r=await api.saveDrop(d);if(r)setDrops(r);setText('');setSaving(false)}; |
| const del=async(id)=>{const r=await api.delDrop(id);if(r)setDrops(r)}; |
| const react=async(drop,emoji)=>{const rx={...(drop.reactions||{})};const me=getUser();const users=rx[emoji]||[];if(users.includes(me))rx[emoji]=users.filter(u=>u!==me);else rx[emoji]=[...users,me]; |
| const updated={...drop,reactions:rx};const r=await api.saveDrop(updated);if(r)setDrops(r)}; |
| const today=new Date().toISOString().slice(0,10);const grouped={};drops.forEach(d=>{const day=d.date?.slice(0,10)||today;(grouped[day]||(grouped[day]=[])).push(d)}); |
| const days=Object.keys(grouped).sort().reverse();const dayLabel=d=>{if(d===today)return '์ค๋';const y=new Date();y.setDate(y.getDate()-1);if(d===y.toISOString().slice(0,10))return '์ด์ ';return d}; |
| const EMOJIS=['๐','๐ฅ','๐','โค๏ธ']; |
| return<div style={{flex:1,display:'flex',flexDirection:'column',overflow:'hidden'}}> |
| <div className="drop-input"><input className="fi2" value={text} onChange={e=>setText(e.target.value)} placeholder={getUser()+'๋, ์ค๋ ํ ์ผ์ ํ์ค๋ก...'} onKeyDown={e=>{if(e.key==='Enter')submit()}}/><button className="btn btn-p" onClick={submit} disabled={saving}>{saving?'โณ':'โ๏ธ'}</button></div> |
| <div style={{flex:1,overflowY:'auto'}}>{days.map(day=><div key={day}><div style={{padding:'6px 12px',fontSize:8,fontWeight:700,color:'#94a3b8',background:'#f8fafc',borderBottom:'1px solid #e2e8f0'}}>{dayLabel(day)} ({grouped[day].length})</div> |
| {grouped[day].sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(d=><div key={d.id} className="drop-item"> |
| <div className="drop-avatar">{(d.author||'?')[0]}</div> |
| <div className="drop-body"><span className="drop-name">{d.author}</span><span className="drop-time">{d.date?.slice(11,16)}</span><div className="drop-text">{d.text}</div> |
| <div className="drop-actions">{EMOJIS.map(e=>{const cnt=((d.reactions||{})[e]||[]).length;const me=(d.reactions||{})[e]?.includes(getUser()); |
| return<span key={e} className={'drop-react'+(me?' active':'')} onClick={()=>react(d,e)}>{e}{cnt>0&&<span style={{fontSize:8,marginLeft:2}}>{cnt}</span>}</span>})} |
| {d.author===getUser()&&<span className="drop-del" onClick={()=>del(d.id)}>โ</span>}</div> |
| </div></div>)}</div>)}</div></div>} |
| |
| |
| function WorkRequest({reqs,setReqs}){ |
| const me=getUser();const[isNew,setIsNew]=useState(false);const[targets,setTargets]=useState([]); |
| const[content,setContent]=useState('');const[deadline,setDeadline]=useState('');const[priority,setPriority]=useState('์ผ๋ฐ');const[saving,setSaving]=useState(false); |
| const[replyTo,setReplyTo]=useState(null);const[replyText,setReplyText]=useState(''); |
| const submit=async()=>{if(!targets.length){alert('๋์ ์ ํ');return}if(!content.trim()){alert('๋ด์ฉ ์
๋ ฅ');return} |
| setSaving(true);const item={id:gid(),from:me,targets,content:content.trim(),deadline,priority,responses:[],createdAt:new Date().toISOString(),status:'๋๊ธฐ'}; |
| const r=await api.saveReq(item);if(r)setReqs(r);setContent('');setDeadline('');setTargets([]);setPriority('์ผ๋ฐ');setIsNew(false);setSaving(false)}; |
| const reply=async(reqId)=>{if(!replyText.trim())return;setSaving(true);const req=reqs.find(r=>r.id===reqId);if(!req)return; |
| const updated={...req,responses:[...(req.responses||[]),{author:me,text:replyText.trim(),date:new Date().toISOString()}]}; |
| const responded=new Set([...(req.responses||[]).map(r=>r.author),me]);if(req.targets.every(t=>responded.has(t)))updated.status='์๋ฃ'; |
| const r=await api.saveReq(updated);if(r)setReqs(r);setReplyText('');setReplyTo(null);setSaving(false)}; |
| const del=async(id)=>{const r=await api.delReq(id);if(r)setReqs(r)}; |
| const myPending=reqs.filter(r=>r.targets?.includes(me)&&!(r.responses||[]).some(rp=>rp.author===me)&&r.status==='๋๊ธฐ'); |
| const sorted=[...reqs].sort((a,b)=>{if(a.status!==b.status)return a.status==='๋๊ธฐ'?-1:1;if(a.priority!==b.priority)return a.priority==='๊ธด๊ธ'?-1:1;return(b.createdAt||'').localeCompare(a.createdAt||'')}); |
| return<div style={{flex:1,display:'flex',flexDirection:'column',overflow:'hidden'}}> |
| <div style={{padding:'8px 12px',display:'flex',justifyContent:'space-between',alignItems:'center',borderBottom:'1px solid #e2e8f0'}}> |
| <div style={{fontSize:12,fontWeight:700}}>๐ค ์
๋ฌด ํ์กฐ {myPending.length>0&&<span className="wr-badge">{myPending.length}</span>}</div> |
| <button className="btn btn-n" onClick={()=>setIsNew(!isNew)}>๏ผ ์์ฒญ</button></div> |
| {isNew&&<div style={{padding:10,background:'#f8fafc',borderBottom:'1px solid #e2e8f0'}}> |
| <div className="wr-pri"><div className={priority==='์ผ๋ฐ'?'sel-n':''} onClick={()=>setPriority('์ผ๋ฐ')}>๐ ์ผ๋ฐ</div><div className={priority==='๊ธด๊ธ'?'sel-u':''} onClick={()=>setPriority('๊ธด๊ธ')}>๐จ ๊ธด๊ธ</div></div> |
| <div style={{fontSize:8,fontWeight:700,color:'#94a3b8',marginBottom:3}}>๋์ (๋ณต์)</div> |
| <div className="wr-targets">{ALL_M.filter(m=>m!==me).map(m=><div key={m} className={'wr-tg'+(targets.includes(m)?' sel':'')} onClick={()=>setTargets(p=>p.includes(m)?p.filter(n=>n!==m):[...p,m])}>{m}</div>)}</div> |
| <input className="fi2" value={content} onChange={e=>setContent(e.target.value)} placeholder="์์ฒญ ๋ด์ฉ" style={{marginBottom:4}} onKeyDown={e=>{if(e.key==='Enter')submit()}}/> |
| <div style={{display:'flex',gap:4,alignItems:'center'}}><span style={{fontSize:8,color:'#94a3b8'}}>๋ง๊ฐ</span><input type="date" className="fi2" value={deadline} onChange={e=>setDeadline(e.target.value)} style={{width:130}}/><div className="sp2"/> |
| <button className="btn btn-g" onClick={()=>setIsNew(false)}>์ทจ์</button><button className="btn btn-p" onClick={submit} disabled={saving}>{saving?'โณ':'๐ค'}</button></div></div>} |
| <div style={{flex:1,overflowY:'auto',padding:'6px 10px'}}>{sorted.length===0&&<div style={{textAlign:'center',color:'#94a3b8',fontSize:10,padding:20}}>์์ฒญ์ด ์์ต๋๋ค</div>} |
| {sorted.map(r=>{const needReply=r.targets?.includes(me)&&!(r.responses||[]).some(rp=>rp.author===me)&&r.status==='๋๊ธฐ'; |
| return<div key={r.id} className="wr" style={r.priority==='๊ธด๊ธ'?{borderLeft:'3px solid #ef4444'}:needReply?{borderLeft:'3px solid #f59e0b'}:r.status==='์๋ฃ'?{opacity:.6}:{}}> |
| <div className="wr-h">{r.priority==='๊ธด๊ธ'&&<span className="wr-status ws-urg">๐จ ๊ธด๊ธ</span>}<span className="wr-from">{r.from}</span><span className="wr-arr">โ</span><span className="wr-to">{(r.targets||[]).join(', ')}</span> |
| <span className={'wr-status '+(r.status==='๋๊ธฐ'?'ws-wait':'ws-done')}>{r.status==='๋๊ธฐ'?'โณ':'โ
'}</span></div> |
| <div className="wr-content">{r.content}</div> |
| <div className="wr-meta"><span>{r.createdAt?.slice(0,10)}</span>{r.deadline&&<span className="wr-dl">๋ง๊ฐ {r.deadline}</span>}{r.from===me&&<span style={{cursor:'pointer',color:'#ef4444'}} onClick={()=>del(r.id)}>์ญ์ </span>}</div> |
| {(r.responses||[]).length>0&&<div className="wr-resp">{r.responses.map((rp,i)=><div key={i} className="wr-resp-item"><span className="wr-resp-name">โ
{rp.author}</span><span className="wr-resp-text">{rp.text}</span><span className="wr-resp-time">{rp.date?.slice(0,10)}</span></div>)}</div>} |
| {needReply&&<div className="wr-reply">{replyTo===r.id?<><input className="fi2" value={replyText} onChange={e=>setReplyText(e.target.value)} placeholder="์๋ต..." style={{flex:1}} onKeyDown={e=>{if(e.key==='Enter')reply(r.id)}}/><button className="btn btn-p" onClick={()=>reply(r.id)}>์๋ต</button></> |
| :<button className="btn btn-n" onClick={()=>setReplyTo(r.id)} style={{fontSize:8}}>โ๏ธ ์๋ต</button>}</div>} |
| {r.targets?.filter(t=>!(r.responses||[]).some(rp=>rp.author===t)).length>0&&r.status==='๋๊ธฐ'&&<div style={{marginTop:3,fontSize:7,color:'#94a3b8'}}>โณ {r.targets.filter(t=>!(r.responses||[]).some(rp=>rp.author===t)).join(', ')}</div>} |
| </div>})}</div></div>} |
| |
| |
| function WeeklyReport({tasks,drops}){const now=new Date();const ws=new Date(now);ws.setDate(now.getDate()-now.getDay()+1);const wsStr=ws.toISOString().slice(0,10); |
| const wd=drops.filter(d=>(d.date||'').slice(0,10)>=wsStr);const bp={};wd.forEach(d=>{(bp[d.author]||(bp[d.author]=[])).push(d)}); |
| const ct=tasks.filter(t=>CAL_TABS.includes(t.tab||'gov'));const in7=new Date(now.getTime()+7*86400000); |
| const up=ct.filter(t=>t.deadline&&new Date(t.deadline)>=now&&new Date(t.deadline)<=in7).sort((a,b)=>a.deadline.localeCompare(b.deadline)); |
| const bt={};CAL_TABS.forEach(tid=>bt[tid]=ct.filter(t=>(t.tab||'gov')===tid).length); |
| const wn=Math.ceil(((now-new Date(now.getFullYear(),0,1))/86400000+new Date(now.getFullYear(),0,1).getDay()+1)/7); |
| return<div className="dash"><div className="rpt"><div className="rpt-t">๐ ์ฃผ๊ฐ ๋ฆฌํฌํธ โ {now.getFullYear()}-W{String(wn).padStart(2,'0')}</div><div className="rpt-s"> |
| <b>๐ ์ ์ฒด:</b> {ct.length}๊ฑด ({CAL_TABS.map(tid=>TL[tid]+' '+bt[tid]).join(', ')})<br/><br/> |
| <b>๐ฌ ์ด๋ฒ ์ฃผ ({wd.length}๊ฑด):</b><br/>{Object.entries(bp).map(([n,ds])=><div key={n} style={{margin:'3px 0'}}><b>{n}</b> ({ds.length}): {ds.map(d=>d.text).join(' / ')}</div>)} |
| {Object.keys(bp).length===0&&'๊ธฐ๋ก ์์'}<br/><br/> |
| <b>๐
๋ง๊ฐ ({up.length}๊ฑด):</b><br/>{up.map(t=><div key={t.id} style={{margin:'2px 0'}}>{t.deadline} <span className={'ttg '+(TC[t.tab]||'')}>{TL[t.tab]}</span> {t.title} {t.assignee&&<span style={{color:'#3b82f6'}}>({t.assignee})</span>}</div>)} |
| {up.length===0&&'์์'}</div></div></div>} |
| |
| |
| function LinkPocket({pockets,setPockets}){ |
| const[isNew,setIsNew]=useState(false);const[f,setF]=useState({link:'',comment:'',tags:''}); |
| const add=async()=>{if(!f.link.trim()&&!f.comment.trim())return;const item={...f,tags:(f.tags||'').split(',').map(s=>s.trim()).filter(Boolean),id:gid(),date:new Date().toISOString(),author:getUser()};const r=await api.savePocket(item);if(r)setPockets(r);setF({link:'',comment:'',tags:''});setIsNew(false)}; |
| const del=async(id)=>{const r=await api.delPocket(id);if(r)setPockets(r)}; |
| const sorted=[...pockets].sort((a,b)=>(b.date||'').localeCompare(a.date||'')); |
| return<div className="nf"><div className="nf-h"><div className="nf-t">๐ ๋งํฌ ์ฃผ๋จธ๋ ({pockets.length})</div><button className="btn btn-n" onClick={()=>setIsNew(!isNew)}>๏ผ ๋์ง๊ธฐ</button></div> |
| {isNew&&<div style={{background:'#f8fafc',border:'1px solid #e2e8f0',borderRadius:6,padding:10,marginBottom:8}}> |
| <input className="fi2" value={f.link} onChange={e=>setF(p=>({...p,link:e.target.value}))} placeholder="URL" style={{marginBottom:4}}/> |
| <input className="fi2" value={f.comment} onChange={e=>setF(p=>({...p,comment:e.target.value}))} placeholder="ํ์ค ์ฝ๋ฉํธ" style={{marginBottom:4}} onKeyDown={e=>{if(e.key==='Enter')add()}}/> |
| <input className="fi2" value={f.tags} onChange={e=>setF(p=>({...p,tags:e.target.value}))} placeholder="ํ๊ทธ (์ผํ ๊ตฌ๋ถ: ๊ฒฝ์์ฌ, ๊ธฐ์ )" style={{marginBottom:4}}/> |
| <div style={{display:'flex',gap:4}}><button className="btn btn-p" onClick={add}>๋ฑ๋ก</button><button className="btn btn-g" onClick={()=>setIsNew(false)}>์ทจ์</button></div></div>} |
| <div className="pkt-grid">{sorted.length===0&&<div style={{gridColumn:'1/-1',textAlign:'center',color:'#94a3b8',fontSize:10,padding:20}}>๋งํฌ๋ฅผ ๋์ ธ์ฃผ์ธ์</div>} |
| {sorted.map(p=><div key={p.id} className="pkt">{p.comment&&<div className="pkt-c">{p.comment}</div>} |
| {p.link&&<a href={p.link} target="_blank" rel="noopener" className="pkt-l">{p.link}</a>} |
| {(p.tags||[]).length>0&&<div style={{display:'flex',gap:2,flexWrap:'wrap'}}>{p.tags.map((t,i)=><span key={i} className="pkt-tag">#{t}</span>)}</div>} |
| <div className="pkt-m"><span style={{fontWeight:600}}>{p.author}</span><span>{p.date?.slice(0,10)}</span>{p.author===getUser()&&<span style={{cursor:'pointer',color:'#ef4444'}} onClick={()=>del(p.id)}>โ</span>}</div></div>)}</div></div>} |
| |
| |
| function MagicLibrary(){const CATS=['์์ฐ์ฑ','LLM','์์AI','๋ฐ์ดํฐ','๋ฒค์น๋งํฌ','์์ด์ ํธ','ํนํ','์ธํ๋ผ','์๋ฎฌ๋ ์ด์
','๊ธฐํ']; |
| const DEF=[{id:'b1',name:'FINAL Bench',cat:'๋ฒค์น๋งํฌ',keywords:['AGI','TICOS'],stars:5,overview:'AGI ๊ฒ์ฆ ๋ฒค์น๋งํฌ. HF ๊ธ๋ก๋ฒ 5์.',link:'https://huggingface.co/datasets',img:'',memo:''},{id:'b2',name:'ํ๊ทธ์ํธ ๋ฐ๋๋',cat:'์์AI',keywords:['ํ๊ตญ์ด'],stars:5,overview:'ํ๊ตญ์ด AI ์์ ์์ฑ.',link:'https://ginigen.ai',img:'',memo:''},{id:'b3',name:'SiteAgent',cat:'์์ด์ ํธ',keywords:['๋ธ๋ผ์ฐ์ '],stars:4,overview:'๋ธ๋ผ์ฐ์ DOM AI ์์ด์ ํธ.',link:'',img:'',memo:''},{id:'b4',name:'Proto-AGI ํนํ',cat:'ํนํ',keywords:['์ฐฝ๋ฐ์ฑ','SLAI'],stars:5,overview:'8๊ฑด ํนํ.',link:'',img:'',memo:''},{id:'b5',name:'AETHER',cat:'LLM',keywords:['๋ฉํ์ธ์ง'],stars:4,overview:'5๋ ๊ธฐ๋ฅ.',link:'',img:'',memo:''}]; |
| const[books,setBooks]=useState(()=>{try{return JSON.parse(localStorage.getItem('vd_lib'))||DEF}catch(e){return DEF}}); |
| const[selId,setSelId]=useState(null);const[isNew,setIsNew]=useState(false); |
| const[f,setF]=useState({id:'',name:'',cat:'๊ธฐํ',keywords:[],stars:3,overview:'',link:'',img:'',memo:''}); |
| const saveAll=l=>{localStorage.setItem('vd_lib',JSON.stringify(l));setBooks(l)};const u=(k,v)=>setF(p=>({...p,[k]:v}));const sel=selId?books.find(b=>b.id===selId):null; |
| const Stars=({n,onChange})=><div style={{display:'flex',gap:1,cursor:onChange?'pointer':'default'}}>{[1,2,3,4,5].map(i=><span key={i} style={{fontSize:12,color:i<=n?'#f59e0b':'#e2e8f0'}} onClick={()=>onChange&&onChange(i)}>โ
</span>)}</div>; |
| return<div className="main"><div className="left" style={{width:280,minWidth:280}}><div style={{padding:'6px 10px',display:'flex',justifyContent:'space-between',alignItems:'center'}}><span style={{fontSize:11,fontWeight:700}}>๐ ๋ง๋ฒ์ฑ
</span><button className="btn btn-n" onClick={()=>{setF({id:'',name:'',cat:'๊ธฐํ',keywords:[],stars:3,overview:'',link:'',img:'',memo:''});setSelId(null);setIsNew(true)}}>๏ผ</button></div><div className="dv2"/> |
| <div className="tl2">{books.map(b=><div key={b.id} className={'tk2'+(b.id===selId?' ac':'')} onClick={()=>{setF({...b,keywords:b.keywords||[]});setSelId(b.id);setIsNew(false)}}><div className="tb2"><div className="tn2">{b.name}</div><div className="tm2"><span style={{fontSize:7,fontWeight:600,padding:'1px 3px',borderRadius:2,background:'#eff6ff',color:'#3b82f6'}}>{b.cat}</span><Stars n={b.stars}/></div></div></div>)}</div></div> |
| {isNew||sel?<div className="right fade2"><div className="dh3"><div className="dt3">{isNew?'๐ ๋ฑ๋ก':'๐ ์์ธ'}</div></div><div className="fm2"> |
| <div className="fr2"><div className="fg2"><span className="fl2">ํ๋ก์ ํธ๋ช
</span><input className="fi2" value={f.name} onChange={e=>u('name',e.target.value)}/></div><div className="fg2"><span className="fl2">๋ถ๋ฅ</span><select className="fs2" value={f.cat} onChange={e=>u('cat',e.target.value)}>{CATS.map(c=><option key={c}>{c}</option>)}</select></div></div> |
| <div className="fr2"><div className="fg2"><span className="fl2">์ค์๋</span><Stars n={f.stars} onChange={v=>u('stars',v)}/></div><div className="fg2"><span className="fl2">ํค์๋</span><input className="fi2" value={(f.keywords||[]).join(', ')} onChange={e=>u('keywords',e.target.value.split(',').map(s=>s.trim()).filter(Boolean))}/></div></div> |
| <div className="fg2 full"><span className="fl2">๊ฐ์</span><textarea className="ft2" value={f.overview} onChange={e=>u('overview',e.target.value)} style={{minHeight:60}}/></div> |
| <div className="fg2 full"><span className="fl2">๋งํฌ</span><input className="fi2" value={f.link} onChange={e=>u('link',e.target.value)}/></div> |
| <div className="fg2 full"><span className="fl2">์บก์ฒ</span><input type="file" accept="image/*" onChange={e=>{const file=e.target.files[0];if(!file)return;const r=new FileReader();r.onload=ev=>u('img',ev.target.result);r.readAsDataURL(file)}} style={{fontSize:9}}/>{f.img&&<img src={f.img} style={{maxWidth:'100%',maxHeight:180,borderRadius:5,border:'1px solid #e2e8f0',marginTop:3}} alt=""/>}</div> |
| <div className="fg2 full"><span className="fl2">๋ฉ๋ชจ</span><textarea className="ft2" value={f.memo} onChange={e=>u('memo',e.target.value)}/></div> |
| <div className="fa2">{!isNew&&sel&&<button className="btn btn-d" onClick={()=>{saveAll(books.filter(b=>b.id!==f.id));setSelId(null);setIsNew(false)}}>๐๏ธ</button>}<div className="sp2"/><button className="btn btn-g" onClick={()=>{setSelId(null);setIsNew(false)}}>์ทจ์</button> |
| <button className="btn btn-p" onClick={()=>{if(!f.name.trim())return;const item={...f,id:f.id||gid()};const idx=books.findIndex(b=>b.id===item.id);if(idx>=0){const n=[...books];n[idx]=item;saveAll(n)}else saveAll([item,...books]);setSelId(item.id);setIsNew(false)}}>๐พ</button></div></div></div> |
| :<div className="right"><div className="de3"><div className="ic">๐</div><div style={{fontSize:11,color:'#94a3b8'}}>์ ํ ๋๋ ๋ฑ๋ก</div></div></div>}</div>} |
| |
| |
| function NewsFeed(){const[news,setNews]=useState([]);const[ld,setLd]=useState(true);const[ft,setFt]=useState('all');const RC={ํต์ฌ:'r-c',์ฃผ๋ชฉ:'r-h',์ฐธ๊ณ :'r-m',์ผ๋ฐ:'r-g'}; |
| const ln=async(f)=>{setLd(true);const r=await fetch(f?'/api/news?refresh=true':'/api/news',{headers:authHeaders()});if(r.status===401){doLogout();return}setNews(await r.json()||[]);setLd(false)}; |
| useEffect(()=>{ln(false)},[]);const tags=[...new Set(news.flatMap(n=>n.tags||[]))];const fil=ft==='all'?news:news.filter(n=>(n.tags||[]).includes(ft)); |
| return<div className="nf"><div className="nf-h"><div className="nf-t">๐ฐ AI ๋ด์ค ({news.length})</div><button className="nf-r" onClick={()=>ln(true)}>{ld?'โณ':'๐'}</button></div> |
| <div className="nf-fs"><div className={'nf-f'+(ft==='all'?' active':'')} onClick={()=>setFt('all')}>์ ์ฒด</div>{tags.map(t=><div key={t} className={'nf-f'+(ft===t?' active':'')} onClick={()=>setFt(t)}>{t}</div>)}</div> |
| {ld&&news.length===0?<div className="ld2" style={{padding:30}}><div className="spn2"/>์์ง ์ค...</div> |
| :<div className="nc-grid">{fil.map((n,i)=><div key={i} className="nc"><div style={{display:'flex',justifyContent:'space-between',gap:3}}><span className="nc-t">{n.title}</span><span className={'nc-rel '+(RC[n.relevance]||'r-g')}>{n.relevance}</span></div> |
| <div className="nc-tags">{(n.tags||[]).map(t=><span key={t} className="nc-tag" style={{background:(n.colors?.[t]||'#64748b')+'18',color:n.colors?.[t]||'#64748b'}}>{t}</span>)}</div> |
| <div className="nc-s">{n.summary}</div><div className="nc-m"><span>{n.source}</span><span>{n.date}</span><a href={n.url} target="_blank" rel="noopener" className="nc-l">๐</a></div></div>)}</div>}</div>} |
| |
| function RndTab({pockets,setPockets}){const[sub,setSub]=useState('news');return<div className="main" style={{flexDirection:'column'}}><div className="mgr"><div className="stabs"> |
| <div className={'stab'+(sub==='news'?' active':'')} onClick={()=>setSub('news')}>๐ฐ ๋ด์ค</div><div className={'stab'+(sub==='library'?' active':'')} onClick={()=>setSub('library')}>๐ ๋ง๋ฒ์ฑ
</div> |
| <div className={'stab'+(sub==='pocket'?' active':'')} onClick={()=>setSub('pocket')}>๐ ๋งํฌ์ฃผ๋จธ๋</div></div></div> |
| {sub==='news'?<NewsFeed/>:sub==='library'?<MagicLibrary/>:<LinkPocket pockets={pockets} setPockets={setPockets}/>}</div>} |
| |
| function MgmtTab({allTasks,saving,setSaving,setAllTasks}){const[sub,setSub]=useState('tasks'); |
| return<div className="main" style={{flexDirection:'column'}}><div className="mgr"><div className="stabs"> |
| <div className={'stab'+(sub==='tasks'?' active':'')} onClick={()=>setSub('tasks')}>๐ ์
๋ฌด</div> |
| <div className={'stab'+(sub==='files'?' active':'')} onClick={()=>setSub('files')}>๐ ์๋ฃ์ค</div></div></div> |
| {sub==='tasks'?<CalTab key="mgmt" allTasks={allTasks} tabId="mgmt" saving={saving} setSaving={setSaving} setAllTasks={setAllTasks}/>:<FileLibrary/>}</div>} |
| |
| function FileLibrary(){const[files,setFiles]=useState([]);const[ld,setLd]=useState(true);const[uploading,setUploading]=useState(false);const[isNew,setIsNew]=useState(false); |
| const[title,setTitle]=useState('');const[desc,setDesc]=useState('');const[selFile,setSelFile]=useState(null); |
| const load=async()=>{setLd(true);const r=await fetch('/api/files',{headers:authHeaders()});if(r.ok)setFiles(await r.json());setLd(false)}; |
| useEffect(()=>{load()},[]); |
| const upload=async()=>{if(!selFile)return;setUploading(true);const fd=new FormData();fd.append('file',selFile);fd.append('title',title||selFile.name);fd.append('desc',desc);fd.append('author',getUser()); |
| const r=await fetch('/api/files/upload',{method:'POST',headers:{'Authorization':'Bearer '+getToken()},body:fd});if(r.ok){await load();setTitle('');setDesc('');setSelFile(null);setIsNew(false)}setUploading(false)}; |
| const del=async(id)=>{await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE',headers:authHeaders()});load()}; |
| const fmtSz=b=>b>1048576?(b/1048576).toFixed(1)+'MB':b>1024?(b/1024).toFixed(0)+'KB':b+'B'; |
| return<div className="nf"><div className="nf-h"><div className="nf-t">๐ ์๋ฃ์ค ({files.length})</div><button className="btn btn-n" onClick={()=>setIsNew(!isNew)}>๏ผ ์
๋ก๋</button></div> |
| {isNew&&<div style={{background:'#f8fafc',border:'1px solid #e2e8f0',borderRadius:6,padding:10,marginBottom:8}}> |
| <input className="fi2" value={title} onChange={e=>setTitle(e.target.value)} placeholder="์ ๋ชฉ" style={{marginBottom:4}}/> |
| <input className="fi2" value={desc} onChange={e=>setDesc(e.target.value)} placeholder="ํ์ค ์ค๋ช
" style={{marginBottom:4}}/> |
| <input type="file" onChange={e=>setSelFile(e.target.files[0])} style={{fontSize:9,marginBottom:4}}/> |
| <div style={{display:'flex',gap:4}}><button className="btn btn-p" onClick={upload} disabled={uploading}>{uploading?'โณ':'๐ค'}</button><button className="btn btn-g" onClick={()=>setIsNew(false)}>์ทจ์</button></div></div>} |
| {files.sort((a,b)=>(b.date||'').localeCompare(a.date||'')).map(f=><div key={f.id} className="pkt"><div style={{display:'flex',justifyContent:'space-between',gap:6}}> |
| <div><div style={{fontSize:10,fontWeight:700}}>{f.title||f.filename}</div>{f.desc&&<div className="pkt-c">{f.desc}</div>} |
| <div className="pkt-m"><span style={{fontWeight:600}}>{f.author}</span><span>{f.date?.slice(0,10)}</span><span>{fmtSz(f.size||0)}</span></div></div> |
| <div style={{display:'flex',gap:3,flexShrink:0}}><a href={'/api/files/download/'+encodeURIComponent(f.id)+'?token='+getToken()} className="btn btn-p" style={{textDecoration:'none',fontSize:8}} target="_blank">โฌ</a> |
| {f.author===getUser()&&<button className="btn btn-d" style={{fontSize:8,padding:'3px 6px'}} onClick={()=>del(f.id)}>โ</button>}</div></div></div>)}</div>} |
| |
| |
| function md2html(t){if(!t)return ''; |
| |
| let h=t.replace(/```(\w*)\n([\s\S]*?)```/g,'<pre style="background:#1e293b;color:#e2e8f0;padding:8px;border-radius:5px;font-size:9px;overflow-x:auto;font-family:JetBrains Mono,monospace;margin:6px 0"><code>$2</code></pre>'); |
| h=h.replace(/`([^`]+)`/g,'<code style="background:#f1f5f9;padding:1px 3px;border-radius:2px;font-size:9px">$1</code>'); |
| |
| const lines=h.split('\n');const out=[];let tbl=[]; |
| const flushTable=()=>{if(tbl.length===0)return; |
| let html='<div style="overflow-x:auto;margin:8px 0"><table style="border-collapse:collapse;width:100%;font-size:10px">'; |
| let isFirst=true; |
| tbl.forEach(row=>{if(/^[\s\|\-:]+$/.test(row.replace(/\|/g,'').trim()))return; |
| const cells=row.split('|').slice(1,-1);if(cells.length===0)return; |
| if(isFirst){html+='<tr>'+cells.map(c=>'<th style="background:#f1f5f9;border:1px solid #e2e8f0;padding:6px 10px;font-weight:700;font-size:10px;color:#1e293b;text-align:left;white-space:nowrap">'+c.trim()+'</th>').join('')+'</tr>';isFirst=false;} |
| else{html+='<tr>'+cells.map(c=>'<td style="border:1px solid #e2e8f0;padding:6px 10px;font-size:10px;color:#475569;line-height:1.5;vertical-align:top">'+c.trim()+'</td>').join('')+'</tr>';}}); |
| html+='</table></div>';out.push(html);tbl=[]}; |
| lines.forEach(l=>{if(l.trim().startsWith('|')&&l.trim().endsWith('|')){tbl.push(l)}else{flushTable();out.push(l)}});flushTable(); |
| h=out.join('\n'); |
| h=h.replace(/^### (.+)$/gm,'<div style="font-size:12px;font-weight:700;margin:10px 0 4px;color:#1e293b;border-left:3px solid #3b82f6;padding-left:8px">$1</div>'); |
| h=h.replace(/^## (.+)$/gm,'<div style="font-size:13px;font-weight:800;margin:12px 0 6px;color:#1e293b;border-bottom:1px solid #e2e8f0;padding-bottom:4px">$1</div>'); |
| h=h.replace(/\*\*(.+?)\*\*/g,'<b style="color:#1e293b">$1</b>'); |
| h=h.replace(/^[\-\*] (.+)$/gm,'<div style="padding-left:12px;margin:2px 0;position:relative"><span style="position:absolute;left:0">โข</span> $1</div>'); |
| h=h.replace(/\n/g,'<br/>');return h} |
| |
| function WizardTab({tasks,drops,pockets,reqs}){ |
| const[msgs,setMsgs]=useState(()=>{try{return JSON.parse(localStorage.getItem('vd_chat'))||[{role:'ai',text:'๐ง ๋น๋๋ํํธ ๋ง๋ฒ์ฌ์
๋๋ค. ๋ฌด์์ด๋ ๋ฌผ์ด๋ณด์ธ์!'}]}catch(e){return[{role:'ai',text:'๐ง ๋ง๋ฒ์ฌ์
๋๋ค.'}]}}); |
| const[input,setInput]=useState('');const[loading,setLoading]=useState(false); |
| useEffect(()=>{try{localStorage.setItem('vd_chat',JSON.stringify(msgs.slice(-50)))}catch(e){}},[msgs]); |
| const ct=tasks.filter(t=>CAL_TABS.includes(t.tab||'gov'));const now=new Date();const today=now.toISOString().slice(0,10); |
| const in7=new Date(now.getTime()+7*86400000);const urgent=ct.filter(t=>t.deadline&&new Date(t.deadline)>=now&&new Date(t.deadline)<=in7); |
| const todayDrops=drops.filter(d=>(d.date||'').slice(0,10)===today);const me=getUser(); |
| const myPending=(reqs||[]).filter(r=>r.targets?.includes(me)&&!(r.responses||[]).some(rp=>rp.author===me)&&r.status==='๋๊ธฐ'); |
| const ctx=()=>`[๋น๋๋ํํธ]\n๊ณผ์ ${ct.length}๊ฑด:\n${ct.slice(0,30).map(t=>`- [${TL[t.tab]||t.tab}] ${t.title} (๋ง๊ฐ:${t.deadline||'๋ฏธ์ '}, ๋ด๋น:${t.assignee||''}, ์ํ:${t.status||t.stage||t.prStatus||t.mgmtStatus||''})`).join('\n')}\n\nํ์ค ์ต๊ทผ20:\n${drops.slice(-20).map(d=>`- ${d.author}: ${d.text} (${d.date?.slice(0,10)})`).join('\n')}\n\n๋งํฌ:\n${(pockets||[]).slice(-10).map(p=>`- ${p.author}: ${p.comment||''} ${p.link||''}`).join('\n')}\n\nํ์กฐ ๋๊ธฐ:\n${(reqs||[]).filter(r=>r.status==='๋๊ธฐ').slice(0,10).map(r=>`- ${r.from}โ${r.targets.join(',')} "${r.content}"`).join('\n')}\n\n์ค๋: ${today}`; |
| const ask=async(q)=>{if(!q.trim()||loading)return;setInput('');setMsgs(p=>[...p,{role:'user',text:q}]);setLoading(true); |
| try{const r=await fetch('/api/chat',{method:'POST',headers:authHeaders(),body:JSON.stringify({messages:[{role:'system',content:'๋น๋๋ํํธ ๋ด๋ถ ๋น์ "๋ง๋ฒ์ฌ". ํ๊ตญ์ด. ๋งํฌ๋ค์ด ํ/๋ฆฌ์คํธ ์ ๊ทน ํ์ฉ. ๋ฐ์ดํฐ ๊ธฐ๋ฐ ์ ํ ๋ต๋ณ.'},{role:'user',content:ctx()+'\n\n์ง๋ฌธ: '+q}]})}); |
| const d=await r.json();setMsgs(p=>[...p,{role:'ai',text:d.error?'โ ๏ธ '+d.error:(d.text||'์๋ต ์์')}])}catch(e){setMsgs(p=>[...p,{role:'ai',text:'โ ๏ธ '+e.message}])}setLoading(false)}; |
| const QUICK=[{i:'๐
',l:'์ด๋ฒ ์ฃผ ๋ง๊ฐ',q:'์ด๋ฒ ์ฃผ ๋ง๊ฐ ๊ณผ์ ๋ฅผ ํญ๋ณ, ๋ด๋น์๋ณ ํ๋ก.'},{i:'๐',l:'์ ์ฒด ํํฉ',q:'์ ์ฒด ๊ณผ์ ํํฉ์ ํญ๋ณ ํ๋ก.'},{i:'๐ฅ',l:'๋ด๋น์๋ณ',q:'๋ด๋น์๋ณ ๊ณผ์ ์์ ๋ง๊ฐ ์๋ฐ์ ํ๋ก.'},{i:'๐ฌ',l:'ํ๋ ์์ฝ',q:'์ด๋ฒ ์ฃผ ์ค๋์ ํ์ค ๊ธฐ๋ฐ ํ๋ ์์ฝ.'},{i:'๐ค',l:'๋ฏธ์๋ต ํ์กฐ',q:'์
๋ฌด ํ์กฐ ๋ฏธ์๋ต ๊ฑด ์ ๋ฆฌ.'},{i:'๐',l:'์์
ํํฉ',q:'์ ํด/์์
/ํฌ์ ๋ ๋จ๊ณ๋ณ ํ.'},{i:'๐๏ธ',l:'์ ๋ถ๊ณผ์ ',q:'์ ๋ถ๊ณผ์ ํํฉ ์ฃผ๊ด๋ถ์๋ณ ํ.'},{i:'โ๏ธ',l:'ํ๋ก์ ํธ',q:'ํ๋ก์ ํธ ์งํ๋ฅ ์ ๋ฆฌ.'}]; |
| return<div className="main" style={{overflow:'hidden'}}> |
| <div style={{width:200,minWidth:200,borderRight:'1px solid #e2e8f0',display:'flex',flexDirection:'column',background:'#f8fafc',overflow:'hidden'}}> |
| <div style={{padding:'8px 10px',fontSize:10,fontWeight:700,borderBottom:'1px solid #e2e8f0'}}>โก ๋น ๋ฅธ ์ง๋ฌธ</div> |
| <div style={{flex:1,overflowY:'auto',padding:4}}>{QUICK.map((q,i)=><div key={i} onClick={()=>ask(q.q)} style={{padding:'5px 8px',fontSize:9,cursor:'pointer',borderRadius:4,marginBottom:2,color:'#475569',display:'flex',gap:4,alignItems:'center',border:'1px solid #e2e8f0',background:'#fff'}} |
| onMouseEnter={e=>e.currentTarget.style.borderColor='#3b82f6'} onMouseLeave={e=>e.currentTarget.style.borderColor='#e2e8f0'}><span style={{fontSize:12}}>{q.i}</span>{q.l}</div>)}</div> |
| <div style={{padding:6,borderTop:'1px solid #e2e8f0',fontSize:7,color:'#94a3b8'}}> |
| <div style={{fontWeight:700,marginBottom:2}}>๐ ์ค์๊ฐ</div> |
| <div>๊ณผ์ <b style={{color:'#1e293b'}}>{ct.length}</b> | ์๋ฐ <b style={{color:'#ef4444'}}>{urgent.length}</b></div> |
| <div>ํ๋ <b style={{color:'#3b82f6'}}>{todayDrops.length}</b> | ํ์กฐ <b style={{color:'#f59e0b'}}>{myPending.length}</b></div> |
| <div style={{marginTop:4,cursor:'pointer',color:'#ef4444'}} onClick={()=>{setMsgs([{role:'ai',text:'๐ง ๋ํ ์ด๊ธฐํ๋จ'}]);localStorage.removeItem('vd_chat')}}>๐๏ธ ๋ํ ์ด๊ธฐํ</div> |
| </div></div> |
| <div style={{flex:1,display:'flex',flexDirection:'column',overflow:'hidden'}}> |
| <div className="chat-msgs" style={{flex:1,overflowY:'auto',padding:'10px 14px'}}> |
| {msgs.map((m,i)=><div key={i} className={'chat-msg '+m.role}>{m.role==='ai'?<div className="cb3" dangerouslySetInnerHTML={{__html:md2html(m.text)}}/>:<div className="cb3">{m.text}</div>}</div>)} |
| {loading&&<div className="chat-msg ai"><div className="cb3">๐ง ์๊ฐ ์ค...</div></div>}</div> |
| <div className="chat-input"><input className="fi2" value={input} onChange={e=>setInput(e.target.value)} placeholder="๋ง๋ฒ์ฌ์๊ฒ ๋ฌผ์ด๋ณด์ธ์..." onKeyDown={e=>{if(e.key==='Enter')ask(input)}}/><button className="btn btn-n" onClick={()=>ask(input)} disabled={loading}>๐ง</button></div></div></div>} |
| |
| |
| function DashOverview({tasks,drops,reqs}){ |
| const[search,setSearch]=useState(''); |
| const[brief,setBrief]=useState(null);const[briefLoading,setBriefLoading]=useState(false); |
| const ct=tasks.filter(t=>CAL_TABS.includes(t.tab||'gov'));const now=new Date();const in7=new Date(now.getTime()+7*86400000); |
| const urgent=ct.filter(t=>t.deadline&&new Date(t.deadline)>=now&&new Date(t.deadline)<=in7).sort((a,b)=>a.deadline.localeCompare(b.deadline)); |
| const bt={};CAL_TABS.forEach(tid=>bt[tid]=ct.filter(t=>(t.tab||'gov')===tid).length); |
| const byP={};ALL_M.forEach(m=>{byP[m]=ct.filter(t=>t.assignee===m).length}); |
| const today=now.toISOString().slice(0,10);const todayDrops=drops.filter(d=>(d.date||'').slice(0,10)===today); |
| const me=getUser();const myPending=(reqs||[]).filter(r=>r.targets?.includes(me)&&!(r.responses||[]).some(rp=>rp.author===me)&&r.status==='๋๊ธฐ'); |
| const getRoles=n=>{const r=[];Object.entries(MGR).forEach(([tid,v])=>{if(v.m===n)r.push({tab:tid,role:'์ '});if(v.s.includes(n))r.push({tab:tid,role:'๋ถ'})});return r}; |
| const recent=ct.sort((a,b)=>(b.deadline||'').localeCompare(a.deadline||'')).slice(0,5); |
| const filtered=search?ct.filter(t=>(t.title||'').includes(search)||(t.assignee||'').includes(search)):null; |
| const loadBrief=async(force)=>{setBriefLoading(true);try{const r=await fetch('/api/briefing',{method:'POST',headers:authHeaders(),body:JSON.stringify({user:me,force})}); |
| const d=await r.json();setBrief(d)}catch(e){setBrief({text:'โ ๏ธ ๋ธ๋ฆฌํ ์์ฑ ์คํจ'})}setBriefLoading(false)}; |
| useEffect(()=>{loadBrief(false)},[]); |
| |
| return<div className="dash"> |
| {/* ์์นจ ๋ธ๋ฆฌํ */} |
| <div style={{background:'linear-gradient(135deg,#eff6ff,#faf5ff)',border:'1px solid #c7d2fe',borderRadius:8,padding:12,marginBottom:12}}> |
| <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:6}}> |
| <span style={{fontSize:12,fontWeight:700}}>๐ง {me}๋์ ๋ธ๋ฆฌํ</span> |
| <button className="btn btn-g" onClick={()=>loadBrief(true)} disabled={briefLoading} style={{fontSize:8}}>{briefLoading?'โณ ์์ฑ ์ค...':'๐ ์๋ก ์์ฑ'}</button></div> |
| {briefLoading&&!brief?<div style={{textAlign:'center',padding:10,color:'#94a3b8',fontSize:10}}>๐ง AI๊ฐ ๋ธ๋ฆฌํ์ ์ค๋นํ๊ณ ์์ต๋๋ค...</div> |
| :brief?<><div dangerouslySetInnerHTML={{__html:md2html(brief.text)}} style={{fontSize:11,lineHeight:1.7,color:'#334155'}}/> |
| {brief.org_text&&<div style={{marginTop:8,padding:10,background:'rgba(255,255,255,.8)',borderRadius:6,border:'1px solid #e2e8f0'}}> |
| <div style={{fontSize:10,fontWeight:700,color:'#7c3aed',marginBottom:4}}>๐ ์กฐ์ง ๋ธ๋ฆฌํ (CEO ์ ์ฉ)</div> |
| <div dangerouslySetInnerHTML={{__html:md2html(brief.org_text)}} style={{fontSize:10,lineHeight:1.6,color:'#334155'}}/></div>}</> |
| :<div style={{color:'#94a3b8',fontSize:9,textAlign:'center',padding:6}}>๋ธ๋ฆฌํ ๋ก๋ฉ ์ค...</div>} |
| </div> |
| <div className="dash-search"><input value={search} onChange={e=>setSearch(e.target.value)} placeholder="๐ ์ ์ฒด ๊ฒ์ (๊ณผ์ ๋ช
, ๋ด๋น์)"/></div> |
| {filtered&&<div style={{marginBottom:10}}><div className="st">๐ ๊ฒ์ ๊ฒฐ๊ณผ ({filtered.length}๊ฑด)</div> |
| {filtered.slice(0,10).map(t=>{const dd=dday(t.deadline);return<div key={t.id} className="tk2"><div className={'pb2 '+(TCFG[t.tab]?TCFG[t.tab].bar(t):'pm2')}/><div className="tb2"><div className="tn2">{t.title}</div> |
| <div className="tm2"><span className={'ttg '+(TC[t.tab]||'')}>{TL[t.tab]}</span>{dd&&<span className={'dday '+dd.c}>{dd.t}</span>}<span>{t.deadline}</span>{t.assignee&&<span>โข {t.assignee}</span>}</div></div></div>})}</div>} |
| {!search&&<>{urgent.length>0&&<><div className="st">๐จ ๋ง๊ฐ ์๋ฐ ({urgent.length}๊ฑด)</div><div className="urg">{urgent.slice(0,5).map(t=>{const dd=dday(t.deadline);return<div key={t.id} className="urg-i">{dd&&<span className={'dday '+dd.c} style={{marginRight:4}}>{dd.t}</span>}<span className={'ttg '+(TC[t.tab]||'')}>{TL[t.tab]}</span> {t.title} {t.assignee&&<span style={{color:'#3b82f6'}}>({t.assignee})</span>}</div>})}</div></>} |
| {myPending.length>0&&<><div className="st">๐ค ๋ํํ
์จ ์์ฒญ ({myPending.length})</div><div className="urg" style={{background:'#fffbeb',borderColor:'#fed7aa'}}>{myPending.map(r=><div key={r.id} className="urg-i" style={{color:'#92400e'}}>{r.priority==='๊ธด๊ธ'&&<span style={{color:'#ef4444',fontWeight:700}}>๐จ </span>}<b>{r.from}</b> "{r.content}" {r.deadline&&<span style={{color:'#dc2626'}}>๋ง๊ฐ {r.deadline}</span>}</div>)}</div></>} |
| <div className="st">๐ ํญ๋ณ</div> |
| <div className="kpi-r"><div className="kpi"><div className="kv">{ct.length}</div><div className="kl">์ ์ฒด</div></div>{CAL_TABS.map(tid=><div key={tid} className="kpi"><div className="kv" style={{color:TCLR[tid]}}>{bt[tid]}</div><div className="kl">{TL[tid]}</div></div>)}</div> |
| <div className="st">๐ ์ต๊ทผ ๋ฑ๋ก</div> |
| {recent.map(t=>{const dd=dday(t.deadline);return<div key={t.id} className="tk2" style={{cursor:'default'}}><div className={'pb2 '+(TCFG[t.tab]?TCFG[t.tab].bar(t):'pm2')}/><div className="tb2"><div className="tn2">{t.title}</div> |
| <div className="tm2"><span className={'ttg '+(TC[t.tab]||'')}>{TL[t.tab]}</span>{dd&&<span className={'dday '+dd.c}>{dd.t}</span>}<span>{t.deadline}</span>{t.assignee&&<span>โข {t.assignee}</span>}</div></div></div>})} |
| <div className="st" style={{marginTop:8}}>๐ฌ ์ค๋์ ํ์ค ({todayDrops.length})</div> |
| {todayDrops.length>0?todayDrops.slice(0,5).map(d=><div key={d.id} className="drop-item" style={{borderBottom:'1px solid #f1f5f9',padding:'4px 0'}}><div className="drop-avatar">{(d.author||'?')[0]}</div><div className="drop-body"><span className="drop-name">{d.author}</span><span className="drop-time">{d.date?.slice(11,16)}</span><div className="drop-text">{d.text}</div></div></div>) |
| :<div style={{background:'#f8fafc',border:'1px solid #e2e8f0',borderRadius:5,padding:8,textAlign:'center',color:'#94a3b8',fontSize:9,marginBottom:8}}>์ค๋ ๊ธฐ๋ก ์์</div>} |
| <div className="st" style={{marginTop:8}}>๐ฅ ์ธ์ ({MEMBERS.length}+{ADVISORS.length})</div> |
| <div className="pcard">{[...MEMBERS,...ADVISORS].map(m=>{const roles=getRoles(m);const adv=ADVISORS.includes(m); |
| return<div key={m} className="pcd" style={adv?{borderLeft:'2px solid #f59e0b'}:{}}> |
| <div className="pcd-n">{m}{adv&&<span style={{fontSize:7,color:'#d97706',marginLeft:3}}>์๋ฌธ</span>}</div> |
| <div className="pcd-r">{roles.map((r,i)=><span key={i} className="pcd-role" style={{background:r.role==='์ '?'#eff6ff':'#f1f5f9',color:r.role==='์ '?'#3b82f6':'#64748b'}}>{TL[r.tab]} {r.role}</span>)}</div> |
| <div className="pcd-s"><span>๊ณผ์ </span><span className="pcd-v">{byP[m]||0}</span></div></div>})}</div></>} |
| </div>} |
| |
| |
| function AiInsights(){ |
| const me=getUser(); |
| const[radar,setRadar]=useState(null);const[conn,setConn]=useState(null);const[weekly,setWeekly]=useState(null); |
| const[rLoading,setRLoading]=useState(false);const[cLoading,setCLoading]=useState(false);const[wLoading,setWLoading]=useState(false); |
| const isCeo=me==='๊น๋ฏผ์'||me==='๊นํ๋ด'; |
| const loadRadar=async()=>{setRLoading(true);try{const r=await fetch('/api/radar',{method:'POST',headers:authHeaders(),body:JSON.stringify({user:me})});setRadar(await r.json())}catch(e){setRadar({text:'โ ๏ธ ์ค๋ฅ'})}setRLoading(false)}; |
| const loadConn=async()=>{setCLoading(true);try{const r=await fetch('/api/connections',{method:'POST',headers:authHeaders(),body:JSON.stringify({user:me})});setConn(await r.json())}catch(e){setConn({text:'โ ๏ธ ์ค๋ฅ'})}setCLoading(false)}; |
| const loadWeekly=async()=>{setWLoading(true);try{const r=await fetch('/api/weekly-ai',{method:'POST',headers:authHeaders(),body:JSON.stringify({user:me})});setWeekly(await r.json())}catch(e){setWeekly({text:'โ ๏ธ ์ค๋ฅ'})}setWLoading(false)}; |
| return<div className="dash"> |
| <div style={{display:'grid',gridTemplateColumns:isCeo?'1fr 1fr 1fr':'1fr 1fr',gap:8,marginBottom:12}}> |
| <button className="btn btn-n" onClick={loadRadar} disabled={rLoading} style={{padding:'10px',fontSize:11,justifyContent:'center'}}>๐ฏ {rLoading?'๋ถ์ ์ค...':'๊ธฐํ ๋ ์ด๋ ์คํ'}</button> |
| <button className="btn btn-n" onClick={loadConn} disabled={cLoading} style={{padding:'10px',fontSize:11,justifyContent:'center'}}>๐ฎ {cLoading?'๋ถ์ ์ค...':'์ฐ๊ฒฐ ๋ฐ๊ฒฌ ์คํ'}</button> |
| {isCeo&&<button className="btn btn-n" onClick={loadWeekly} disabled={wLoading} style={{padding:'10px',fontSize:11,justifyContent:'center',background:'linear-gradient(135deg,#7c3aed,#ec4899)'}}>๐ {wLoading?'๋ถ์ ์ค...':'์กฐ์ง ์ง๋ฅ ๋ฆฌํฌํธ'}</button>} |
| </div> |
| {radar&&<div style={{background:'#fff',border:'1px solid #bfdbfe',borderRadius:10,padding:16,marginBottom:12,boxShadow:'0 2px 8px rgba(59,130,246,.08)'}}> |
| <div style={{fontSize:13,fontWeight:800,color:'#1e40af',marginBottom:8,display:'flex',alignItems:'center',gap:6}}>๐ฏ ๊ธฐํ ๋ ์ด๋<span style={{fontSize:8,color:'#94a3b8',fontWeight:400,marginLeft:'auto'}}>AI ๋ถ์ ๊ฒฐ๊ณผ</span></div> |
| <div dangerouslySetInnerHTML={{__html:md2html(radar.text)}} style={{fontSize:11,lineHeight:1.7,color:'#334155'}}/></div>} |
| {conn&&<div style={{background:'#fff',border:'1px solid #c4b5fd',borderRadius:10,padding:16,marginBottom:12,boxShadow:'0 2px 8px rgba(139,92,246,.08)'}}> |
| <div style={{fontSize:13,fontWeight:800,color:'#6d28d9',marginBottom:8,display:'flex',alignItems:'center',gap:6}}>๐ฎ ์ฐ๊ฒฐ ๋ฐ๊ฒฌ<span style={{fontSize:8,color:'#94a3b8',fontWeight:400,marginLeft:'auto'}}>ํฌ๋ก์ค ๋ถ์</span></div> |
| <div dangerouslySetInnerHTML={{__html:md2html(conn.text)}} style={{fontSize:11,lineHeight:1.7,color:'#334155'}}/></div>} |
| {weekly&&<div style={{background:'linear-gradient(135deg,#faf5ff,#fdf2f8)',border:'1px solid #d8b4fe',borderRadius:10,padding:16,marginBottom:12,boxShadow:'0 2px 8px rgba(168,85,247,.1)'}}> |
| <div style={{fontSize:13,fontWeight:800,color:'#7c3aed',marginBottom:8,display:'flex',alignItems:'center',gap:6}}>๐ ์ฃผ๊ฐ ์กฐ์ง ์ง๋ฅ ๋ฆฌํฌํธ<span style={{fontSize:8,color:'#a78bfa',fontWeight:400,marginLeft:'auto'}}>CEO ์ ์ฉ</span></div> |
| <div dangerouslySetInnerHTML={{__html:md2html(weekly.text)}} style={{fontSize:11,lineHeight:1.7,color:'#334155'}}/></div>} |
| {!radar&&!conn&&!weekly&&<div style={{textAlign:'center',color:'#94a3b8',fontSize:11,padding:40}}> |
| <div style={{fontSize:36,marginBottom:10}}>๐ฏ๐ฎ</div> |
| AI๊ฐ ๋น๋๋ํํธ์ ์ ์ฒด ๋ฐ์ดํฐ๋ฅผ ๋ถ์ํ์ฌ<br/>์ฌ์
๊ธฐํ์ ์จ์ ์ฐ๊ฒฐ์ ์ฐพ์๋
๋๋ค.<br/><br/> |
| <b>๐ฏ ๊ธฐํ ๋ ์ด๋:</b> ๋ด์ค/ํธ๋ ๋ โ ๋น๋๋ํํธ ๊ธฐ์ ๋งค์นญ<br/> |
| <b>๐ฎ ์ฐ๊ฒฐ ๋ฐ๊ฒฌ:</b> ๊ณผ์ +๋งํฌ+ํ๋ ํฌ๋ก์ค ๋ถ์<br/> |
| {isCeo&&<><b>๐ ์กฐ์ง ์ง๋ฅ ๋ฆฌํฌํธ:</b> ํ๋ ํจํด/๋ณ๋ชฉ/๊ธฐํ (CEO ์ ์ฉ)<br/></>} |
| </div>} |
| </div>} |
| |
| |
| function App(){ |
| const[tab,setTab]=useState('dashboard');const[tasks,setTasks]=useState([]);const[drops,setDrops]=useState([]);const[pockets,setPockets]=useState([]);const[reqs,setReqs]=useState([]); |
| const[loading,setLoading]=useState(true);const[saving,setSaving]=useState(false);const[clock,setClock]=useState('');const[dashSub,setDashSub]=useState('overview'); |
| useEffect(()=>{const tick=()=>{setClock(new Date().toLocaleString('ko-KR',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}))};tick();const iv=setInterval(tick,30000);return()=>clearInterval(iv)},[]); |
| useEffect(()=>{(async()=>{setLoading(true);const[t,d,p,rq]=await Promise.all([api.getTasks(),api.getDrops(),api.getPockets(),api.getReqs()]); |
| setTasks((t||[]).map(x=>({...x,tab:x.tab||'gov'})));setDrops(d||[]);setPockets(p||[]);setReqs(rq||[]);setLoading(false)})()},[]); |
| const tabCn={};CAL_TABS.forEach(tid=>{tabCn[tid]=tasks.filter(t=>(t.tab||'gov')===tid).length});const total=CAL_TABS.reduce((s,tid)=>s+tabCn[tid],0);const showCal=CAL_TABS.includes(tab); |
| const me=getUser();const reqBadge=(reqs||[]).filter(r=>r.targets?.includes(me)&&!(r.responses||[]).some(rp=>rp.author===me)&&r.status==='๋๊ธฐ').length; |
| |
| return<div> |
| <div className="hd"><div className="hd-b"><div className="hd-l">๐ง</div><div><div className="hd-t">ํ๊ทธ์ํธ ๋ง๋ฒํ๊ต</div><div className="hd-s">VIDRAFT</div></div></div> |
| <div className="hd-r"><span style={{fontSize:10,color:'rgba(255,255,255,.9)',fontWeight:600}}>๐ค {me}</span><div className="hd-c">{clock}</div><span className="hd-o" onClick={()=>doLogout()}>๋ก๊ทธ์์</span></div></div> |
| <div className="tabs">{TABS.map(t=>{const cnt=t.id==='dashboard'?total:(tabCn[t.id]||0);const hasAlert=t.id==='dashboard'&&reqBadge>0; |
| return<div key={t.id} className={'tab'+(tab===t.id?' active':'')} onClick={()=>setTab(t.id)}>{t.icon} {t.label}{cnt>0&&<span className="bg">{cnt}</span>}{hasAlert&&<span className="alert-dot"/>}</div>})}</div> |
| {tab!=='dashboard'&&tab!=='wizard'&&<MgrBar tabId={tab}/>} |
| {tab==='dashboard'&&<div className="mgr"><div className="stabs"> |
| {[{id:'overview',l:'๐ ํํฉ'},{id:'drops',l:'๐ฌ ํ์ค'},{id:'collab',l:'๐ค ํ์กฐ'+(reqBadge>0?' ('+reqBadge+')':'')},{id:'calendar',l:'๐
์บ๋ฆฐ๋'},{id:'insight',l:'๐ฏ AI ์ธ์ฌ์ดํธ'},{id:'report',l:'๐ ๋ฆฌํฌํธ'}].map(s=> |
| <div key={s.id} className={'stab'+(dashSub===s.id?' active':'')} onClick={()=>setDashSub(s.id)}>{s.l}</div>)}</div></div>} |
| {loading?<div className="main"><div className="ld2"><div className="spn2"/>๋ก๋ฉ...</div></div>: |
| tab==='dashboard'?( |
| dashSub==='overview'?<div className="main"><DashOverview tasks={tasks} drops={drops} reqs={reqs}/></div>: |
| dashSub==='drops'?<div className="main" style={{flexDirection:'column'}}><DailyDrop drops={drops} setDrops={setDrops}/></div>: |
| dashSub==='collab'?<div className="main" style={{flexDirection:'column'}}><WorkRequest reqs={reqs} setReqs={setReqs}/></div>: |
| dashSub==='insight'?<div className="main"><AiInsights/></div>: |
| dashSub==='report'?<div className="main"><WeeklyReport tasks={tasks} drops={drops}/></div>: |
| <CalTab key="dash" allTasks={tasks} tabId="dashboard" saving={saving} setSaving={setSaving} setAllTasks={setTasks}/>): |
| showCal&&tab!=='mgmt'?<CalTab key={tab} allTasks={tasks} tabId={tab} saving={saving} setSaving={setSaving} setAllTasks={setTasks}/>: |
| tab==='mgmt'?<MgmtTab allTasks={tasks} saving={saving} setSaving={setSaving} setAllTasks={setTasks}/>: |
| tab==='rnd'?<RndTab pockets={pockets} setPockets={setPockets}/>: |
| tab==='wizard'?<WizardTab tasks={tasks} drops={drops} pockets={pockets} reqs={reqs}/>:null} |
| </div>} |
| window.bootReactApp=function(){ReactDOM.createRoot(document.getElementById('vd-app')).render(<App/>)}; |
| </script> |
| </body> |
| </html> |