library(shiny) library(ggplot2) library(dplyr) library(tidyr) library(corrplot) library(plotly) # ────────────────────────────────────────── # 列定義 # ────────────────────────────────────────── COL_LABELS <- list( "整備_実績_売上_(単位:円)" = "整備_売上", "整備_実績_一般車検_(単位:円)" = "整備_一般車検", "整備_実績_整備・板金_(単位:円)" = "整備_整備・板金", "整備_実績_その他_(単位:円)" = "整備_その他", "整備_実績_原価_(単位:円)" = "整備_原価", "整備_実績_粗利_(単位:円)" = "整備_粗利", "整備_実績_販管費_(単位:円)" = "整備_販管費", "整備_実績_営業利益_(単位:円)" = "整備_営業利益", "整備_KPI(一般車検件数)_実績_(単位:台)" = "車検件数", "整備_KPI(整備・板金数)_実績_(単位:台)" = "整備・板金数", "商品_実績_売上_(単位:円)" = "商品_売上", "商品_実績_原価_(単位:円)" = "商品_原価", "商品_実績_粗利_(単位:円)" = "商品_粗利", "商品_実績_販管費_(単位:円)" = "商品_販管費", "商品_実績_営業利益_(単位:円)" = "商品_営業利益", "運送_実績_売上_(単位:円)" = "運送_売上", "運送_実績_原価_(単位:円)" = "運送_原価", "運送_実績_粗利_(単位:円)" = "運送_粗利", "運送_実績_販管費_(単位:円)" = "運送_販管費", "運送_実績_営業利益_(単位:円)" = "運送_営業利益", "運送_KPI(自社ドライバー数)_実績_(単位:台)" = "自社ドライバー数", "運送_KPI(他社ドライバー数)_実績_(単位:台)" = "他社ドライバー数", "レンタル_実績_売上_(単位:円)" = "レンタル_売上", "レンタル_実績_原価_(単位:円)" = "レンタル_原価", "レンタル_実績_粗利_(単位:円)" = "レンタル_粗利", "レンタル_実績_販管費_(単位:円)" = "レンタル_販管費", "レンタル_実績_営業利益_(単位:円)" = "レンタル_営業利益", "レンタル_KPI(レンタル台数)_実績_(単位:台)" = "レンタル台数" ) short_label <- function(col) { lbl <- COL_LABELS[[col]] if (!is.null(lbl)) lbl else col } # ────────────────────────────────────────── # データ読み込み # ────────────────────────────────────────── load_csv <- function(path) { df <- tryCatch({ # UTF-8 BOM付き・CRLF・2行ヘッダー対応 # 1行目: date,x1,x2... (コードキー行) # 2行目: 年_月,整備_実績_売上... (日本語ヘッダー行) # 3行目以降: データ raw <- read.csv(path, header = FALSE, skip = 0, stringsAsFactors = FALSE, encoding = "UTF-8", fileEncoding = "UTF-8-BOM", check.names = FALSE) # 先頭セルのBOM残骸をさらに除去 raw[1, 1] <- gsub("^\uFEFF", "", raw[1, 1]) raw[1, 1] <- gsub("^\\xef\\xbb\\xbf", "", raw[1, 1]) # 1行目がコードキー行かどうか判定 first_cell <- as.character(raw[1, 1]) second_cell <- as.character(raw[1, 2]) is_code_row <- grepl("^date$", first_cell, ignore.case = TRUE) || grepl("^x[0-9]+$", second_cell) if (is_code_row) { # 行1=コードキー, 行2=日本語ヘッダー → 行2をcolnamesに, 行1〜2を削除 colnames(raw) <- as.character(raw[2, ]) raw <- raw[-c(1, 2), ] } else { # 行1=日本語ヘッダー → 行1をcolnamesに, 行1を削除 colnames(raw) <- as.character(raw[1, ]) raw <- raw[-1, ] } raw }, error = function(e) { message("load_csv error: ", conditionMessage(e)) NULL }) if (is.null(df) || nrow(df) == 0) return(NULL) # 年月列を統一(年_月 → 年月) if ("年_月" %in% colnames(df)) { df <- df %>% rename(年月 = `年_月`) } else if (!("年月" %in% colnames(df))) { df <- df %>% mutate(年月 = as.character(df[[1]])) %>% select(年月, everything()) } # 数値列変換 for (col in setdiff(colnames(df), "年月")) { df[[col]] <- suppressWarnings(as.numeric(gsub(",", "", as.character(df[[col]])))) } # 合計行・空行除外 df <- df %>% filter( !is.na(年月), 年月 != "", !grepl("合計|累計|年_月", 年月) ) if (nrow(df) == 0) return(NULL) df } # デモデータパス DEMO_PATH <- "DummyData.csv" DEMO_PATHS <- c( "DummyData.csv", "SampleData.csv", file.path(getwd(), "DummyData.csv"), file.path(getwd(), "SampleData.csv"), "/srv/shiny-server/DummyData.csv", "/srv/shiny-server/SampleData.csv", "/mnt/user-data/uploads/DummyData.csv", "/mnt/user-data/uploads/SampleData.csv" ) load_demo <- function() { for (p in DEMO_PATHS) { if (file.exists(p)) return(load_csv(p)) } NULL } fmt_yen <- function(x) { if (is.na(x)) return("—") if (abs(x) >= 1e8) return(paste0(formatC(x / 1e8, format = "f", digits = 2), "億円")) if (abs(x) >= 1e4) return(paste0(format(round(x / 10000), big.mark = ","), "万円")) paste0(format(round(x), big.mark = ","), "円") } # ────────────────────────────────────────── # CSS / Theme # ────────────────────────────────────────── dashboard_css <- " @import url('https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+JP:wght@300;400;500;700&display=swap'); /* ── TOKENS (light = default) ── */ :root { --bg: #f0f4f8; --bg2: #ffffff; --bg3: #e8eef5; --bg4: #d8e4f0; --border: #c5d3e4; --text: #0f172a; --text2: #475569; --text3: #94a3b8; --accent: #0284c7; --purple: #7c3aed; --gold: #d97706; --success: #059669; --danger: #dc2626; --warn: #d97706; --r: 10px; --shadow: 0 4px 32px rgba(0,0,0,.12); --header-h:56px; --bottom-h:60px; --sidebar-w:230px; } /* ── TOKENS (dark) ── */ body.dark { --bg: #0b0f1a; --bg2: #111827; --bg3: #1a2235; --bg4: #222d42; --border: #2a3550; --text: #e2e8f4; --text2: #7a8db8; --text3: #4a5a7a; --accent: #4fc3f7; --purple: #7c3aed; --gold: #f0b429; --success: #34d399; --danger: #f87171; --warn: #fbbf24; --shadow: 0 4px 32px rgba(0,0,0,.5); } *,*::before,*::after{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;} /* Shiny用: html/bodyにheight:100%やoverflow:hiddenをつけない */ html{height:100%;} body{min-height:100%;font-family:'Noto Sans JP',sans-serif;font-size:13px;line-height:1.6;background:var(--bg);color:var(--text);transition:background .25s,color .25s;} /* Shinyラッパーを透明化 */ .container-fluid,.shiny-bound-output,.tab-content,.tabbable{background:transparent!important;} button{font-family:inherit;cursor:pointer;} @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}} @keyframes fadein{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} @keyframes slideUp{from{transform:translateY(100%);opacity:0}to{transform:none;opacity:1}} /* ── HEADER ── */ .app-header{ height:var(--header-h); background:linear-gradient(135deg,var(--bg2),var(--bg3)); border-bottom:1px solid var(--border); display:flex;align-items:center;justify-content:space-between; padding:0 16px;position:sticky;top:0;left:0;right:0;z-index:200; box-shadow:0 2px 20px rgba(0,0,0,.25); transition:background .25s,border-color .25s; } .hdr-brand{display:flex;align-items:center;gap:10px;min-width:0;} .hdr-logo{ flex-shrink:0;width:32px;height:32px; background:linear-gradient(135deg,var(--accent),#0284c7); border-radius:7px;display:flex;align-items:center;justify-content:center; font-size:13px;font-weight:700;color:#fff; box-shadow:0 0 14px rgba(79,195,247,.3); } .hdr-titles{min-width:0;} .hdr-title{font-family:'Noto Serif JP',serif;font-size:14px;font-weight:700;letter-spacing:.05em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} .hdr-sub{font-size:9px;color:var(--text2);margin-top:1px;display:none;} .hdr-right{display:flex;align-items:center;gap:8px;flex-shrink:0;} .ym-badge{font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--text2);background:var(--bg3);border:1px solid var(--border);padding:3px 10px;border-radius:5px;white-space:nowrap;} .status-pill{display:flex;align-items:center;gap:5px;background:rgba(52,211,153,.1);border:1px solid rgba(52,211,153,.3);border-radius:20px;padding:3px 10px;font-size:10px;color:var(--success);white-space:nowrap;} .status-pill.loading{background:rgba(251,191,36,.1);border-color:rgba(251,191,36,.3);color:var(--warn);} .status-pill.idle{background:rgba(100,116,139,.1);border-color:rgba(100,116,139,.3);color:var(--text3);} .s-dot{width:6px;height:6px;border-radius:50%;background:var(--success);box-shadow:0 0 5px var(--success);animation:pulse 2s infinite;flex-shrink:0;} .status-pill.loading .s-dot,.status-pill.idle .s-dot{background:var(--text3);box-shadow:none;animation:none;} /* theme toggle */ .theme-btn{background:var(--bg3);border:1px solid var(--border);color:var(--text2);border-radius:6px;padding:4px 10px;font-size:15px;transition:.15s;} .theme-btn:hover{color:var(--text);border-color:var(--accent);} /* hamburger */ .hbg{display:none;flex-direction:column;gap:5px;background:none;border:none;padding:6px;} .hbg span{display:block;width:20px;height:2px;background:var(--text2);border-radius:2px;transition:.2s;} .hbg.open span:nth-child(1){transform:rotate(45deg) translate(5px,5px);} .hbg.open span:nth-child(2){opacity:0;} .hbg.open span:nth-child(3){transform:rotate(-45deg) translate(5px,-5px);} /* ── LAYOUT ── */ /* Shiny用: fixed layoutは使わず min-heightベースで展開 */ .app-body{display:grid;grid-template-columns:var(--sidebar-w) 1fr;min-height:calc(100vh - var(--header-h));} /* ── SIDEBAR ── */ .sidebar{background:var(--bg2);border-right:1px solid var(--border);padding:16px 0;overflow-y:auto;min-height:calc(100vh - var(--header-h));transition:background .25s,border-color .25s;} .sb-section{margin-bottom:6px;} .sb-label{padding:4px 16px 2px;font-size:9px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.12em;} .sb-item{display:flex;align-items:center;gap:9px;padding:8px 16px;cursor:pointer;border-left:2px solid transparent;transition:.15s;font-size:12px;color:var(--text2);user-select:none;} .sb-item:hover{background:rgba(79,195,247,.04);color:var(--text);} .sb-item.active{background:rgba(79,195,247,.08);border-left-color:var(--accent);color:var(--text);} .sb-icon{font-size:14px;width:18px;text-align:center;} .sb-divider{height:1px;background:var(--border);margin:8px 16px;} .upload-zone{margin:8px 12px;border:1.5px dashed var(--border);border-radius:8px;padding:14px 8px;text-align:center;cursor:pointer;transition:.2s;color:var(--text3);font-size:11px;line-height:1.9;} .upload-zone:hover,.upload-zone.drag{border-color:var(--accent);color:var(--accent);background:rgba(79,195,247,.04);} .demo-info{padding:6px 16px;font-size:10px;color:var(--text3);line-height:1.8;} /* ── MAIN ── */ .main{padding:24px 20px 60px;} .page{display:none;} .page.active{display:block;animation:fadein .2s ease;} /* ── SECTION HEADER ── */ .sec-hdr{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;flex-wrap:wrap;gap:8px;} .sec-title{font-family:'Noto Serif JP',serif;font-size:17px;font-weight:700;display:flex;align-items:center;gap:8px;} .sec-badge{font-size:10px;font-weight:600;padding:2px 8px;border-radius:4px;background:rgba(79,195,247,.15);color:var(--accent);border:1px solid rgba(79,195,247,.3);letter-spacing:.04em;} .sec-badge.demo{background:rgba(124,58,237,.15);color:#a78bfa;border-color:rgba(124,58,237,.3);} /* ── KPI GRID ── */ .kgrid{display:grid;gap:12px;margin-bottom:18px;} .g4{grid-template-columns:repeat(4,1fr);} .g3{grid-template-columns:repeat(3,1fr);} .g2{grid-template-columns:repeat(2,1fr);} .kpi{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:14px 16px;position:relative;overflow:hidden;transition:transform .15s,box-shadow .15s;} .kpi:hover{transform:translateY(-2px);box-shadow:var(--shadow);} .kpi::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;} .k-accent::before{background:var(--accent);} .k-success::before{background:var(--success);} .k-gold::before{background:var(--gold);} .k-warn::before{background:var(--warn);} .k-danger::before{background:var(--danger);} .k-purple::before{background:var(--purple);} .kpi-icon{font-size:18px;margin-bottom:5px;} .kpi-lbl{font-size:9px;color:var(--text2);text-transform:uppercase;letter-spacing:.08em;margin-bottom:3px;} .kpi-val{font-family:'IBM Plex Mono',monospace;font-size:20px;font-weight:600;letter-spacing:-.5px;line-height:1.2;} .kpi-sub{font-size:10px;color:var(--text2);margin-top:3px;} /* ── TOTAL STRIP ── */ .total-strip{background:linear-gradient(135deg,rgba(79,195,247,.08),rgba(124,58,237,.08));border:1px solid rgba(79,195,247,.2);border-radius:var(--r);padding:18px 22px;display:flex;align-items:center;justify-content:space-between;margin-bottom:18px;gap:12px;flex-wrap:wrap;} .ts-lbl{font-family:'Noto Serif JP',serif;font-size:13px;font-weight:700;color:var(--text2);} .ts-ann{font-size:11px;color:var(--text3);margin-top:2px;} .ts-right{display:flex;align-items:baseline;gap:4px;} .ts-val{font-family:'IBM Plex Mono',monospace;font-size:34px;font-weight:600;color:var(--accent);letter-spacing:-1px;} .ts-unit{font-size:13px;color:var(--text2);} /* ── CARD ── */ .card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:18px;margin-bottom:16px;box-shadow:0 2px 10px rgba(0,0,0,.15);} .card-title{font-family:'Noto Serif JP',serif;font-size:12px;font-weight:700;color:var(--text2);margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:7px;} .grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px;} /* ── TABLE ── */ .tbl-wrap{overflow-x:auto;} table{width:100%;border-collapse:collapse;font-size:12px;} th{background:var(--bg3);padding:8px 12px;text-align:right;font-weight:600;color:var(--text2);font-size:10px;border-bottom:1px solid var(--border);white-space:nowrap;letter-spacing:.03em;} th:first-child{text-align:left;} td{padding:7px 12px;border-bottom:1px solid rgba(42,53,80,.5);text-align:right;font-family:'IBM Plex Mono',monospace;font-size:12px;} td.lc{text-align:left;font-family:'Noto Sans JP',sans-serif;color:var(--text2);} tr:hover td{background:rgba(255,255,255,.015);} .pos{color:var(--success);} .neg{color:var(--danger);} .gld{color:var(--gold);} .b{font-weight:700;} /* ── UPLOAD BANNER ── */ .upload-banner{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:40px 24px;text-align:center;margin-bottom:20px;} .upload-banner .big-ico{font-size:52px;margin-bottom:14px;} .upload-banner h2{font-family:'Noto Serif JP',serif;font-size:17px;margin-bottom:8px;} .upload-banner p{font-size:12px;color:var(--text2);margin-bottom:20px;line-height:1.9;} .btn-row{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;} .btn-primary{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(135deg,var(--accent),#0284c7);color:#fff;font-weight:700;font-size:13px;padding:10px 24px;border-radius:8px;border:none;box-shadow:0 4px 14px rgba(79,195,247,.3);transition:opacity .15s,transform .15s;cursor:pointer;} .btn-primary:hover{opacity:.85;transform:translateY(-1px);} .btn-demo{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(135deg,#7c3aed,#4c1d95);color:#fff;font-weight:700;font-size:13px;padding:10px 24px;border-radius:8px;border:none;box-shadow:0 4px 14px rgba(124,58,237,.35);transition:opacity .15s,transform .15s;cursor:pointer;} .btn-demo:hover{opacity:.85;transform:translateY(-1px);} .btn-demo-sb{display:block;width:calc(100% - 24px);margin:6px 12px 0;background:rgba(124,58,237,.15);border:1px solid rgba(124,58,237,.4);color:#a78bfa;font-size:11px;font-weight:700;padding:8px;border-radius:7px;cursor:pointer;transition:.15s;text-align:center;} .btn-demo-sb:hover{background:rgba(124,58,237,.25);color:#c4b5fd;} /* ── CONTROL PANEL ── */ .ctrl-panel{background:var(--bg2);border:1px solid var(--border);border-radius:var(--r);padding:14px 16px;margin-bottom:16px;} .ctrl-panel label{font-size:11px;color:var(--text2);display:block;margin-bottom:4px;} .ctrl-panel select,.ctrl-panel input[type=checkbox]{accent-color:var(--accent);} .ctrl-panel select{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:5px 8px;font-size:12px;width:100%;} .ctrl-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:end;} /* ── REPORT MODAL ── */ .modal-overlay{display:none;position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.7);backdrop-filter:blur(4px);align-items:flex-end;justify-content:center;} .modal-overlay.open{display:flex;} .modal{background:var(--bg2);border:1px solid var(--border);border-radius:16px 16px 0 0;width:100%;max-width:680px;max-height:92vh;display:flex;flex-direction:column;animation:slideUp .3s cubic-bezier(.25,.8,.25,1);box-shadow:0 -8px 40px rgba(0,0,0,.6);} .modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);} .modal-title{font-family:'Noto Serif JP',serif;font-size:15px;font-weight:700;} .modal-close{background:var(--bg3);border:1px solid var(--border);color:var(--text2);border-radius:6px;padding:5px 10px;font-size:18px;line-height:1;cursor:pointer;transition:.15s;} .modal-close:hover{color:var(--text);} .modal-body{overflow-y:auto;padding:20px;flex:1;} .modal-footer{padding:14px 20px;border-top:1px solid var(--border);display:flex;gap:10px;justify-content:flex-end;flex-wrap:wrap;} /* ── REPORT PREVIEW ── */ .rpt-preview{background:#fff;color:#1a1a2e;border-radius:8px;padding:28px 24px;font-family:'Noto Sans JP',sans-serif;font-size:12px;line-height:1.8;} .rpt-preview h1{font-family:'Noto Serif JP',serif;font-size:18px;font-weight:700;color:#0f172a;border-bottom:2px solid #1a4480;padding-bottom:8px;margin-bottom:16px;} .rpt-preview h2{font-family:'Noto Serif JP',serif;font-size:13px;font-weight:700;color:#1a4480;margin:18px 0 8px;padding-left:10px;border-left:3px solid #4fc3f7;} .rpt-meta{font-size:11px;color:#64748b;margin-bottom:20px;} .rpt-kpi-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:12px 0;} .rpt-kpi{background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px 12px;text-align:center;} .rpt-kpi .rk-lbl{font-size:10px;color:#64748b;margin-bottom:3px;} .rpt-kpi .rk-val{font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:700;color:#0f172a;} .rpt-tbl{width:100%;border-collapse:collapse;margin:8px 0 14px;font-size:11px;} .rpt-tbl th{background:#1a4480;color:#fff;padding:6px 10px;text-align:left;font-size:10px;} .rpt-tbl th:not(:first-child){text-align:right;} .rpt-tbl td{padding:5px 10px;border-bottom:1px solid #e2e8f0;} .rpt-tbl td:not(:first-child){text-align:right;font-family:'IBM Plex Mono',monospace;} .rpt-tbl tr:nth-child(even) td{background:#f8fafc;} .rpt-tbl .tot td{font-weight:700;background:#dbeafe!important;} .rpt-note{background:#f0f9ff;border:1px solid #bae6fd;border-radius:6px;padding:10px 12px;font-size:10px;color:#0369a1;margin-top:10px;line-height:1.8;} .btn-dl{display:inline-flex;align-items:center;gap:6px;background:linear-gradient(135deg,var(--success),#059669);color:#fff;font-weight:700;font-size:12px;padding:9px 20px;border-radius:8px;border:none;box-shadow:0 3px 10px rgba(52,211,153,.3);cursor:pointer;transition:.15s;} .btn-dl:hover{opacity:.85;} .btn-sec{background:var(--bg3);color:var(--text2);border:1px solid var(--border);font-size:12px;padding:9px 16px;border-radius:8px;cursor:pointer;transition:.15s;} .btn-sec:hover{color:var(--text);} /* ── BOTTOM NAV (mobile) ── */ .bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;height:var(--bottom-h);background:var(--bg2);border-top:1px solid var(--border);z-index:199;align-items:stretch;} .bn-item{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer;color:var(--text3);font-size:9px;transition:.15s;border-top:2px solid transparent;user-select:none;} .bn-item:hover{color:var(--text2);} .bn-item.active{color:var(--accent);border-top-color:var(--accent);} .bn-icon{font-size:18px;line-height:1;} .drawer-overlay{display:none;position:fixed;inset:0;z-index:150;background:rgba(0,0,0,.6);} .drawer-overlay.open{display:block;} /* ── SCATTER CHECKBOXES ── */ .check-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 12px;padding:4px 0;} .check-item{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--text2);cursor:pointer;padding:2px 0;} .check-item input{accent-color:var(--accent);} /* ── RESPONSIVE ── */ @media (min-width:769px){.hdr-sub{display:block;}} @media (max-width:768px){ :root{--header-h:52px;} .sidebar{position:fixed;top:var(--header-h);left:0;bottom:0;width:240px;z-index:160;transform:translateX(-100%);transition:transform .25s cubic-bezier(.25,.8,.25,1);height:auto;overflow-y:auto;} .sidebar.open{transform:translateX(0);} .hbg{display:flex;} .ym-badge{display:none;} .app-body{grid-template-columns:1fr;min-height:calc(100vh - var(--header-h));} .main{padding:16px 14px calc(80px + var(--bottom-h));} .bottom-nav{display:flex;} .g4,.g3{grid-template-columns:repeat(2,1fr);} .grid-2{grid-template-columns:1fr;} .g2{grid-template-columns:1fr 1fr;} .kpi-val{font-size:17px;} .total-strip{flex-direction:column;align-items:flex-start;gap:6px;} .ts-val{font-size:26px;} .sec-title{font-size:15px;} .ctrl-row{grid-template-columns:1fr;} .rpt-kpi-row{grid-template-columns:1fr 1fr;} } @media (max-width:420px){ .g4,.g3,.g2{grid-template-columns:1fr;} .kpi-val{font-size:20px;} .rpt-kpi-row{grid-template-columns:1fr;} .modal-footer{flex-direction:column;} .btn-dl,.btn-sec{width:100%;justify-content:center;text-align:center;} .check-grid{grid-template-columns:1fr;} } /* ── Shiny互換オーバーライド ── */ /* Shinyが生成するラッパー要素をリセット */ body > .container-fluid { padding:0 !important; margin:0 !important; } .shiny-output-error { color:var(--danger); font-size:11px; padding:4px; } .shiny-output-error-validation { color:var(--text3); font-size:11px; } /* Shiny tab/panelのデフォルトスタイルを除去 */ .tab-content { background:transparent !important; border:none !important; padding:0 !important; } .tabbable > .nav, .tabbable > .tab-content { display:none; } /* ShinyのfluidPageラッパーを透明に */ body > div.container-fluid { background:transparent !important; } " # ────────────────────────────────────────── # UI # ────────────────────────────────────────── ui <- fluidPage( title = "経営指標分析ダッシュボード", tags$head( tags$meta(name="viewport", content="width=device-width, initial-scale=1.0, maximum-scale=1.0"), tags$style(HTML(dashboard_css)), tags$script(HTML(" // ── navigation ── function nav(page) { document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); document.querySelectorAll('.sb-item').forEach(i=>i.classList.remove('active')); document.querySelectorAll('.bn-item').forEach(i=>i.classList.remove('active')); var el = document.getElementById('page-'+page); if(el) el.classList.add('active'); var sb = document.getElementById('nav-'+page); if(sb) sb.classList.add('active'); var bn = document.getElementById('bn-'+page); if(bn) bn.classList.add('active'); closeSidebar(); // Shinyに現在ページを通知 Shiny.setInputValue('current_page', page, {priority:'event'}); // Plotlyグラフをリサイズ(非表示->表示時の描画崩れを防ぐ) setTimeout(function(){ if(window.Plotly){ document.querySelectorAll('.js-plotly-plot').forEach(function(g){ try{ Plotly.Plots.resize(g); }catch(e){} }); } }, 80); } function toggleDrawer() { var sb=document.getElementById('sidebar'); var hbg=document.getElementById('hbg'); var ov=document.getElementById('d-overlay'); sb.classList.toggle('open'); hbg.classList.toggle('open'); ov.classList.toggle('open'); } function closeSidebar() { document.getElementById('sidebar').classList.remove('open'); document.getElementById('hbg').classList.remove('open'); document.getElementById('d-overlay').classList.remove('open'); } function openReport() { document.getElementById('modal-overlay').classList.add('open'); Shiny.setInputValue('open_report', Date.now(), {priority:'event'}); closeSidebar(); } function closeReport() { document.getElementById('modal-overlay').classList.remove('open'); } // drag & drop function onDragOver(e){e.preventDefault();e.currentTarget.classList.add('drag');} function onDragLeave(e){e.currentTarget.classList.remove('drag');} function onDrop(e){ e.preventDefault();e.currentTarget.classList.remove('drag'); var f=e.dataTransfer.files[0]; if(f){ injectFile(f); } } function injectFile(f){ // Shiny の fileInput (input[type=file]) を探してファイルを注入 var inp = document.querySelector('input[type=file]'); if(!inp) inp = document.querySelector('input[type=file]'); if(!inp) return; try { var dt = new DataTransfer(); dt.items.add(f); inp.files = dt.files; inp.dispatchEvent(new Event('change', {bubbles:true})); } catch(err) { // DataTransfer非対応ブラウザへのフォールバック console.warn('Drag&drop not supported in this browser:', err); } } // theme function toggleTheme(){ document.body.classList.toggle('dark'); var isDark=document.body.classList.contains('dark'); document.getElementById('theme-btn').textContent=isDark?'☀':'🌙'; Shiny.setInputValue('theme_mode', isDark?'dark':'light'); } // status function setStatus(txt,cls){ var pill=document.getElementById('s-pill'); var stxt=document.getElementById('s-txt'); pill.className='status-pill '+cls; stxt.textContent=txt; } $(document).on('shiny:value', function(e){ if(e.name==='status_text'){setStatus(e.value.text,e.value.cls);} if(e.name==='header_ym'){ document.getElementById('hdr-ym').textContent=e.value; document.getElementById('sum-ym').textContent=e.value; } }); // Shiny custom message handlers Shiny.addCustomMessageHandler('statusUpdate', function(d){setStatus(d.text, d.cls);}); Shiny.addCustomMessageHandler('ymUpdate', function(v){ var hym=document.getElementById('hdr-ym'); if(hym) hym.textContent=v; var sym=document.getElementById('sum-ym'); if(sym) sym.textContent=v; }); // init $(function(){ nav('summary'); }); ")) ), # ── HEADER ── tags$header(class="app-header", tags$div(class="hdr-brand", tags$button(class="hbg", id="hbg", onclick="toggleDrawer()", `aria-label`="メニュー", tags$span(), tags$span(), tags$span() ), tags$div(class="hdr-logo", "KPI"), tags$div(class="hdr-titles", tags$div(class="hdr-title", "経営指標分析ダッシュボード"), tags$div(class="hdr-sub", "CSV アップロード → 自動集計・レポート生成") ) ), tags$div(class="hdr-right", tags$span(class="ym-badge", id="hdr-ym", "— 未読込"), tags$button(class="theme-btn", id="theme-btn", onclick="toggleTheme()", "🌙"), tags$div(class="status-pill idle", id="s-pill", tags$div(class="s-dot"), tags$span(id="s-txt", "待機中") ) ) ), # ── DRAWER OVERLAY ── tags$div(class="drawer-overlay", id="d-overlay", onclick="closeSidebar()"), # ── APP BODY ── tags$div(class="app-body", # ── SIDEBAR ── tags$aside(class="sidebar", id="sidebar", tags$div(class="sb-section", tags$div(class="sb-label", "ページ"), tags$div(class="sb-item active", id="nav-summary", onclick="nav('summary')", tags$span(class="sb-icon","📊"), "サマリー"), tags$div(class="sb-item", id="nav-timeseries", onclick="nav('timeseries')", tags$span(class="sb-icon","📈"), "時系列分析"), tags$div(class="sb-item", id="nav-correlation", onclick="nav('correlation')", tags$span(class="sb-icon","🔗"), "相関分析"), tags$div(class="sb-item", id="nav-regression", onclick="nav('regression')", tags$span(class="sb-icon","📉"), "回帰分析"), tags$div(class="sb-item", id="nav-scatter", onclick="nav('scatter')", tags$span(class="sb-icon","🔵"), "詳細散布図"), tags$div(class="sb-item", id="nav-stats", onclick="nav('stats')", tags$span(class="sb-icon","📋"), "統計サマリー"), tags$div(class="sb-item", id="nav-report-btn", onclick="openReport()", tags$span(class="sb-icon","📄"), "評価レポート") ), tags$div(class="sb-divider"), tags$div(class="sb-section", tags$div(class="sb-label", "データ読込"), tags$div(class="upload-zone", id="dz", onclick="(function(){var fi=document.querySelector('input[type=file]');if(fi)fi.click();})()", ondragover="onDragOver(event)", ondragleave="onDragLeave(event)", ondrop="onDrop(event)", tags$div(class="uz-ico", "📂"), "CSVをドロップ", tags$br(), "またはクリック" ), # fileInput を非表示で配置、upload-zone クリックで発火 tags$div(style="display:none", fileInput("csv_file", NULL, accept=".csv") ), tags$script(HTML(" // upload-zone クリックで Shiny の fileInput を開く document.addEventListener('DOMContentLoaded', function(){ var dz = document.getElementById('dz'); if(dz){ dz.onclick = function(){ var fi = document.querySelector('#csv_file + div input[type=file], input[id$=-csv_file]'); if(!fi) fi = document.querySelector('input[type=file]'); if(fi) fi.click(); }; } }); ")), tags$button(class="btn-demo-sb", onclick="Shiny.setInputValue('load_demo',Math.random(),{priority:'event'})", "▶ デモデータで試す") ), tags$div(class="sb-divider"), tags$div(class="sb-section", tags$div(class="sb-label", "対応フォーマット"), tags$div(class="demo-info", "✦ 整備部門 (売上・原価・粗利・販管費・営業利益・KPI)", tags$br(), "✦ 商品部門 (売上・原価・粗利・販管費・営業利益)", tags$br(), "✦ 運送部門 (売上・原価・粗利・販管費・営業利益・KPI)", tags$br(), "✦ レンタル部門 (売上・原価・粗利・販管費・営業利益・KPI)" ) ) ), # ── MAIN ── tags$main(class="main", id="main-area", # ══ SUMMARY ══ tags$div(class="page active", id="page-summary", uiOutput("summary_ui") ), # ══ TIME SERIES ══ tags$div(class="page", id="page-timeseries", tags$div(class="sec-hdr", tags$div(class="sec-title", "📈 時系列分析", uiOutput("ts_badge")), tags$span(class="ym-badge", id="sum-ym", "—") ), tags$div(class="card", tags$div(class="card-title", "💹 各部門 営業利益推移"), plotlyOutput("ts_profit", height="280px") ), tags$div(class="card", tags$div(class="card-title", "💰 各部門 売上高推移"), plotlyOutput("ts_sales", height="280px") ), tags$div(class="card", tags$div(class="card-title", "📌 KPI推移"), plotlyOutput("ts_kpi", height="280px") ) ), # ══ CORRELATION ══ tags$div(class="page", id="page-correlation", tags$div(class="sec-hdr", tags$div(class="sec-title", "🔗 相関分析", uiOutput("corr_badge")) ), tags$div(class="grid-2", tags$div(class="card", tags$div(class="card-title", "🗺 相関係数ヒートマップ"), plotOutput("corr_heatmap", height="480px") ), tags$div(class="card", tags$div(class="card-title", "🏆 相関が強い組み合わせ Top10"), tags$div(class="tbl-wrap", tableOutput("corr_table")) ) ) ), # ══ REGRESSION ══ tags$div(class="page", id="page-regression", tags$div(class="sec-hdr", tags$div(class="sec-title", "📉 回帰分析", uiOutput("reg_badge")) ), tags$div(class="ctrl-panel", tags$div(class="ctrl-row", tags$div( tags$label("説明変数(X)— KPI系"), selectInput("x_var", NULL, choices=c( "車検件数" = "整備_KPI(一般車検件数)_実績_(単位:台)", "整備・板金数" = "整備_KPI(整備・板金数)_実績_(単位:台)", "自社ドライバー数" = "運送_KPI(自社ドライバー数)_実績_(単位:台)", "他社ドライバー数" = "運送_KPI(他社ドライバー数)_実績_(単位:台)", "レンタル台数" = "レンタル_KPI(レンタル台数)_実績_(単位:台)" ), selected="整備_KPI(一般車検件数)_実績_(単位:台)") ), tags$div( tags$label("被説明変数(Y)— 売上・利益系"), selectInput("y_var", NULL, choices=c( "整備_売上" = "整備_実績_売上_(単位:円)", "整備_営業利益" = "整備_実績_営業利益_(単位:円)", "商品_売上" = "商品_実績_売上_(単位:円)", "商品_営業利益" = "商品_実績_営業利益_(単位:円)", "運送_売上" = "運送_実績_売上_(単位:円)", "運送_営業利益" = "運送_実績_営業利益_(単位:円)", "レンタル_売上" = "レンタル_実績_売上_(単位:円)", "レンタル_営業利益" = "レンタル_実績_営業利益_(単位:円)" ), selected="整備_実績_売上_(単位:円)") ) ) ), tags$div(class="grid-2", tags$div(class="card", tags$div(class="card-title", "📊 散布図・回帰直線"), plotlyOutput("reg_scatter", height="300px") ), tags$div(class="card", tags$div(class="card-title", "📋 回帰分析結果"), verbatimTextOutput("reg_results") ) ) ), # ══ SCATTER ══ tags$div(class="page", id="page-scatter", tags$div(class="sec-hdr", tags$div(class="sec-title", "🔵 詳細散布図行列", uiOutput("scat_badge")) ), tags$div(class="card", tags$div(class="card-title", "変数の選択"), uiOutput("scatter_checks") ), tags$div(class="card", tags$div(class="card-title", "🔵 散布図行列"), plotOutput("scatter_matrix", height="600px") ) ), # ══ STATS ══ tags$div(class="page", id="page-stats", tags$div(class="sec-hdr", tags$div(class="sec-title", "📋 統計サマリー", uiOutput("stats_badge")) ), tags$div(class="grid-2", tags$div(class="card", tags$div(class="card-title", "🔧 整備部門"), verbatimTextOutput("stats_seibitsubi") ), tags$div(class="card", tags$div(class="card-title", "🛒 商品部門"), verbatimTextOutput("stats_shohin") ) ), tags$div(class="grid-2", tags$div(class="card", tags$div(class="card-title", "🚚 運送部門"), verbatimTextOutput("stats_unsou") ), tags$div(class="card", tags$div(class="card-title", "🚗 レンタル部門"), verbatimTextOutput("stats_rental") ) ) ) ) # /main ), # /app-body # ── BOTTOM NAV (mobile) ── tags$nav(class="bottom-nav", tags$div(class="bn-item active", id="bn-summary", onclick="nav('summary')", tags$div(class="bn-icon","📊"), "サマリー"), tags$div(class="bn-item", id="bn-timeseries", onclick="nav('timeseries')", tags$div(class="bn-icon","📈"), "時系列"), tags$div(class="bn-item", id="bn-correlation", onclick="nav('correlation')", tags$div(class="bn-icon","🔗"), "相関"), tags$div(class="bn-item", id="bn-regression", onclick="nav('regression')", tags$div(class="bn-icon","📉"), "回帰"), tags$div(class="bn-item", id="bn-report-btn", onclick="openReport()", tags$div(class="bn-icon","📄"), "レポート") ), # ── REPORT MODAL ── tags$div(class="modal-overlay", id="modal-overlay", tags$div(class="modal", tags$div(class="modal-header", tags$div(class="modal-title", "📄 経営指標 評価レポート"), tags$button(class="modal-close", onclick="closeReport()", "✕") ), tags$div(class="modal-body", uiOutput("report_preview") ), tags$div(class="modal-footer", tags$button(class="btn-dl", onclick="window.print()", "🖨 印刷 / PDF保存"), tags$button(class="btn-sec", onclick="closeReport()", "閉じる") ) ) ) ) # ────────────────────────────────────────── # SERVER # ────────────────────────────────────────── server <- function(input, output, session) { # ── データ ── demo_data <- reactive({ load_demo() }) uploaded_data <- reactive({ req(input$csv_file) load_csv(input$csv_file$datapath) }) # デモ切替フラグ use_demo <- reactiveVal(TRUE) observeEvent(input$load_demo, { use_demo(TRUE) }) observeEvent(input$csv_file, { use_demo(FALSE) }) df <- reactive({ if (use_demo()) { d <- demo_data() if (!is.null(d)) return(d) } uploaded_data() }) is_demo <- reactive({ use_demo() && !is.null(demo_data()) }) # ── ページ切替時の再描画トリガー ── # current_page が変わるたびに page_trigger をインクリメント page_trigger <- reactiveVal(0) observeEvent(input$current_page, { page_trigger(page_trigger() + 1) }, ignoreNULL = TRUE) # ── ステータス更新 ── observe({ d <- df() if (!is.null(d)) { txt <- if (is_demo()) "デモデータ表示中" else "データ読込完了" cls <- if (is_demo()) "loading" else "" session$sendCustomMessage("statusUpdate", list(text=txt, cls=cls)) latest_ym <- tail(d$年月, 1) session$sendCustomMessage("ymUpdate", latest_ym) } }) # ── バッジ ── badge_ui <- function() { if (is_demo()) tags$span(class="sec-badge demo", "DEMO") else tags$span(class="sec-badge", "LIVE") } output$ts_badge <- renderUI(badge_ui()) output$corr_badge <- renderUI(badge_ui()) output$reg_badge <- renderUI(badge_ui()) output$scat_badge <- renderUI(badge_ui()) output$stats_badge <- renderUI(badge_ui()) # ── Helper ── get_numeric_cols <- function(d) { cols <- colnames(d)[colnames(d) != "年月"] cols[sapply(cols, function(c) is.numeric(d[[c]]))] } # ───────────────────────────────────────── # SUMMARY # ───────────────────────────────────────── output$summary_ui <- renderUI({ d <- df() if (is.null(d)) { return(tags$div(class="upload-banner", tags$div(class="big-ico","📈"), tags$h2("CSVをアップロードしてください"), tags$p("経営指標CSVをアップロードすると", tags$br(), "ダッシュボードが自動生成されます"), tags$div(class="btn-row", tags$button(class="btn-primary", onclick="(function(){var fi=document.querySelector('input[type=file]');if(fi)fi.click();})()", "📂 CSVを選択"), tags$button(class="btn-demo", onclick="Shiny.setInputValue('load_demo',Math.random(),{priority:'event'})", "▶ デモを見る") ), tags$div(style="margin-top:14px;font-size:10px;color:var(--text3)", "デモモードでサンプルデータを確認できます") )) } latest <- tail(d, 1) prev <- if(nrow(d) >= 2) d[nrow(d)-1,] else latest total_sales <- sum(latest$`整備_実績_売上_(単位:円)`, latest$`商品_実績_売上_(単位:円)`, latest$`運送_実績_売上_(単位:円)`, latest$`レンタル_実績_売上_(単位:円)`, na.rm=TRUE) total_profit <- sum(latest$`整備_実績_営業利益_(単位:円)`, latest$`商品_実績_営業利益_(単位:円)`, latest$`運送_実績_営業利益_(単位:円)`, latest$`レンタル_実績_営業利益_(単位:円)`, na.rm=TRUE) prev_sales <- sum(prev$`整備_実績_売上_(単位:円)`, prev$`商品_実績_売上_(単位:円)`, prev$`運送_実績_売上_(単位:円)`, prev$`レンタル_実績_売上_(単位:円)`, na.rm=TRUE) prev_profit <- sum(prev$`整備_実績_営業利益_(単位:円)`, prev$`商品_実績_営業利益_(単位:円)`, prev$`運送_実績_営業利益_(単位:円)`, prev$`レンタル_実績_営業利益_(単位:円)`, na.rm=TRUE) diff_s <- total_sales - prev_sales diff_p <- total_profit - prev_profit sign_s <- if(diff_s >= 0) "▲" else "▼" sign_p <- if(diff_p >= 0) "▲" else "▼" cls_s <- if(diff_s >= 0) "pos" else "neg" cls_p <- if(diff_p >= 0) "pos" else "neg" kpi_row <- function(icon, lbl, val, sub, cls) { tags$div(class=paste("kpi", cls), tags$div(class="kpi-icon", icon), tags$div(class="kpi-lbl", lbl), tags$div(class="kpi-val", val), tags$div(class="kpi-sub", sub) ) } tagList( tags$div(class="sec-hdr", tags$div(class="sec-title", "📊 経営サマリー", badge_ui()), tags$span(class="ym-badge", id="sum-ym", tail(d$年月, 1)) ), tags$div(class="total-strip", tags$div( tags$div(class="ts-lbl", "直近月 全部門 合計売上"), tags$div(class="ts-ann", paste0("前月比: ", sign_s, " ", fmt_yen(abs(diff_s)))) ), tags$div(class="ts-right", tags$div(class="ts-val", fmt_yen(total_sales)), tags$div(class="ts-unit", "/ 月") ) ), tags$div(class="kgrid g4", kpi_row("🔧", "整備_売上", fmt_yen(latest$`整備_実績_売上_(単位:円)`), paste0("営業利益: ", fmt_yen(latest$`整備_実績_営業利益_(単位:円)`)), "k-accent"), kpi_row("🛒", "商品_売上", fmt_yen(latest$`商品_実績_売上_(単位:円)`), paste0("営業利益: ", fmt_yen(latest$`商品_実績_営業利益_(単位:円)`)), "k-success"), kpi_row("🚚", "運送_売上", fmt_yen(latest$`運送_実績_売上_(単位:円)`), paste0("営業利益: ", fmt_yen(latest$`運送_実績_営業利益_(単位:円)`)), "k-gold"), kpi_row("🚗", "レンタル_売上", fmt_yen(latest$`レンタル_実績_売上_(単位:円)`), paste0("営業利益: ", fmt_yen(latest$`レンタル_実績_営業利益_(単位:円)`)), "k-purple") ), tags$div(class="total-strip", tags$div( tags$div(class="ts-lbl", "直近月 全部門 合計営業利益"), tags$div(class="ts-ann", paste0("前月比: ", sign_p, " ", fmt_yen(abs(diff_p)))) ), tags$div(class="ts-right", tags$div(class="ts-val", style=if(total_profit < 0) "color:var(--danger)" else "", fmt_yen(total_profit)), tags$div(class="ts-unit", "/ 月") ) ), tags$div(class="kgrid g4", kpi_row("📊", "車検件数", paste0(latest$`整備_KPI(一般車検件数)_実績_(単位:台)`, " 台"), "整備KPI", "k-accent"), kpi_row("🔩", "整備・板金数", paste0(latest$`整備_KPI(整備・板金数)_実績_(単位:台)`, " 台"), "整備KPI", "k-accent"), kpi_row("👤", "自社ドライバー数", paste0(latest$`運送_KPI(自社ドライバー数)_実績_(単位:台)`, " 名"), "運送KPI", "k-gold"), kpi_row("🚙", "レンタル台数", paste0(latest$`レンタル_KPI(レンタル台数)_実績_(単位:台)`, " 台"), "レンタルKPI", "k-purple") ), tags$div(class="card", tags$div(class="card-title", "📋 部門別 直近月 損益サマリー"), tags$div(class="tbl-wrap", tags$table( tags$thead(tags$tr( tags$th("部門"), tags$th("売上"), tags$th("原価"), tags$th("粗利"), tags$th("販管費"), tags$th("営業利益"), tags$th("粗利率") )), tags$tbody( do.call(tagList, lapply( list( list("🔧 整備", "整備_実績_売上_(単位:円)","整備_実績_原価_(単位:円)","整備_実績_粗利_(単位:円)","整備_実績_販管費_(単位:円)","整備_実績_営業利益_(単位:円)"), list("🛒 商品", "商品_実績_売上_(単位:円)","商品_実績_原価_(単位:円)","商品_実績_粗利_(単位:円)","商品_実績_販管費_(単位:円)","商品_実績_営業利益_(単位:円)"), list("🚚 運送", "運送_実績_売上_(単位:円)","運送_実績_原価_(単位:円)","運送_実績_粗利_(単位:円)","運送_実績_販管費_(単位:円)","運送_実績_営業利益_(単位:円)"), list("🚗 レンタル","レンタル_実績_売上_(単位:円)","レンタル_実績_原価_(単位:円)","レンタル_実績_粗利_(単位:円)","レンタル_実績_販管費_(単位:円)","レンタル_実績_営業利益_(単位:円)") ), function(row) { s <- as.numeric(latest[[row[[2]]]]) co <- as.numeric(latest[[row[[3]]]]) g <- as.numeric(latest[[row[[4]]]]) sg <- as.numeric(latest[[row[[5]]]]) op <- as.numeric(latest[[row[[6]]]]) grate <- if (!is.na(s) && s != 0) paste0(round(g/s*100, 1), "%") else "—" op_cls <- if (!is.na(op) && op < 0) "neg" else "pos" tags$tr( tags$td(class="lc b", row[[1]]), tags$td(fmt_yen(s)), tags$td(fmt_yen(co)), tags$td(fmt_yen(g)), tags$td(fmt_yen(sg)), tags$td(class=op_cls, tags$b(fmt_yen(op))), tags$td(class="gld", grate) ) } )) ) ) ) ) ) }) # ───────────────────────────────────────── # TIME SERIES # ───────────────────────────────────────── ts_plot <- function(d, cols, label_map, title, ylab) { if (is.null(d)) return(NULL) long <- d %>% select(年月, all_of(cols)) %>% pivot_longer(-年月, names_to="部門", values_to="値") %>% mutate(部門 = label_map[部門]) p <- ggplot(long, aes(x=factor(年月, levels=unique(年月)), y=値, color=部門, group=部門)) + geom_line(linewidth=1) + geom_point(size=2.5) + labs(title=title, x="年月", y=ylab, color="") + theme_minimal(base_size=11) + theme(axis.text.x=element_text(angle=45, hjust=1, size=9, colour="#475569"), axis.text.y=element_text(colour="#475569"), axis.title=element_text(colour="#475569"), plot.background=element_rect(fill="transparent", colour=NA), panel.background=element_rect(fill="transparent", colour=NA), legend.background=element_rect(fill="transparent", colour=NA), legend.text=element_text(colour="#0f172a"), panel.grid.major=element_line(colour="#e2e8f0"), panel.grid.minor=element_blank(), plot.title=element_text(hjust=.5, size=12, face="bold", colour="#0f172a")) ggplotly(p, tooltip=c("x","y","colour")) %>% layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=list(color="#475569"), legend=list(orientation="h", y=-0.25, font=list(color="#0f172a"))) } profit_map <- c( "整備_実績_営業利益_(単位:円)" = "整備", "商品_実績_営業利益_(単位:円)" = "商品", "運送_実績_営業利益_(単位:円)" = "運送", "レンタル_実績_営業利益_(単位:円)" = "レンタル" ) sales_map <- c( "整備_実績_売上_(単位:円)" = "整備", "商品_実績_売上_(単位:円)" = "商品", "運送_実績_売上_(単位:円)" = "運送", "レンタル_実績_売上_(単位:円)" = "レンタル" ) kpi_map <- c( "整備_KPI(一般車検件数)_実績_(単位:台)" = "車検件数", "整備_KPI(整備・板金数)_実績_(単位:台)" = "整備・板金数", "運送_KPI(自社ドライバー数)_実績_(単位:台)" = "自社ドライバー数", "運送_KPI(他社ドライバー数)_実績_(単位:台)" = "他社ドライバー数", "レンタル_KPI(レンタル台数)_実績_(単位:台)" = "レンタル台数" ) output$ts_profit <- renderPlotly({ page_trigger() # ページ切替時に再描画 ts_plot(df(), names(profit_map), profit_map, "各部門 営業利益推移", "営業利益(円)") }) output$ts_sales <- renderPlotly({ page_trigger() ts_plot(df(), names(sales_map), sales_map, "各部門 売上高推移", "売上(円)") }) output$ts_kpi <- renderPlotly({ page_trigger() ts_plot(df(), names(kpi_map), kpi_map, "KPI推移", "件数 / 台数") }) # ───────────────────────────────────────── # CORRELATION # ───────────────────────────────────────── output$corr_heatmap <- renderPlot({ page_trigger() d <- df() req(d) nd <- d %>% select(-年月) %>% select(where(is.numeric)) nd <- nd[complete.cases(nd), ] req(nrow(nd) >= 3) cm <- cor(nd, use="complete.obs") cm[is.na(cm)] <- 0 colnames(cm) <- rownames(cm) <- sapply(colnames(cm), short_label) par(bg="white", col.axis="#475569", col.lab="#475569", col.main="#0f172a") corrplot(cm, method="color", type="full", tl.cex=0.65, tl.col="#0f172a", addCoef.col="#0f172a", number.cex=0.5, col=colorRampPalette(c("#dc2626","#f8fafc","#059669"))(100), bg="white", title="相関係数ヒートマップ", mar=c(0,0,2,0)) }, bg="white") output$corr_table <- renderTable({ d <- df() req(d) nd <- d %>% select(-年月) %>% select(where(is.numeric)) nd <- nd[complete.cases(nd), ] req(nrow(nd) >= 3) cm <- cor(nd, use="complete.obs") cm[is.na(cm)] <- 0 ut <- upper.tri(cm) pairs <- data.frame( 変数1 = sapply(rownames(cm)[row(cm)[ut]], short_label), 変数2 = sapply(colnames(cm)[col(cm)[ut]], short_label), 相関係数 = round(cm[ut], 3) ) %>% arrange(desc(abs(相関係数))) %>% head(10) pairs }, striped=TRUE, hover=TRUE, spacing="s") # ───────────────────────────────────────── # REGRESSION # ───────────────────────────────────────── # x_var / y_var は静的selectInput → renderUI不要 reg_data <- reactive({ d <- df() req(d, input$x_var, input$y_var) xv <- input$x_var yv <- input$y_var if (!(xv %in% colnames(d)) || !(yv %in% colnames(d))) return(NULL) d %>% select(年月, X=all_of(xv), Y=all_of(yv)) %>% filter(!is.na(X), !is.na(Y), is.finite(X), is.finite(Y)) }) output$reg_scatter <- renderPlotly({ page_trigger() rd <- reg_data() req(!is.null(rd), nrow(rd) >= 3) model <- lm(Y ~ X, data=rd) xl <- short_label(input$x_var) yl <- short_label(input$y_var) p <- ggplot(rd, aes(x=X, y=Y, text=paste0("年月: ", 年月))) + geom_point(size=3, color="#4fc3f7", alpha=.7) + geom_smooth(method="lm", se=TRUE, color="#f87171", fill="#f87171", alpha=.15) + labs(title=paste(yl, "vs", xl), x=xl, y=yl) + theme_minimal(base_size=11) + theme(plot.background=element_rect(fill="transparent", colour=NA), panel.background=element_rect(fill="transparent", colour=NA), axis.text=element_text(colour="#475569"), axis.title=element_text(colour="#475569"), plot.title=element_text(hjust=.5, size=12, face="bold", colour="#0f172a"), panel.grid.major=element_line(colour="#e2e8f0"), panel.grid.minor=element_blank()) ggplotly(p, tooltip=c("text","x","y")) %>% layout(paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", font=list(color="#475569")) }) output$reg_results <- renderPrint({ rd <- reg_data() req(!is.null(rd), nrow(rd) >= 3) model <- lm(Y ~ X, data=rd) xl <- short_label(input$x_var) yl <- short_label(input$y_var) cat(sprintf("回帰分析: %s ~ %s\n\n", yl, xl)) s <- summary(model) cat(sprintf("切片 (β₀): %s\n", format(coef(model)[1], big.mark=",", digits=6))) cat(sprintf("傾き (β₁): %s\n", format(coef(model)[2], big.mark=",", digits=6))) cat(sprintf("決定係数 (R²): %.4f\n", s$r.squared)) cat(sprintf("相関係数 (r): %.4f\n", cor(rd$X, rd$Y))) cat(sprintf("p値: %.4f\n", summary(model)$coefficients[2,4])) cat(sprintf("観測数: %d\n", nrow(rd))) cat("\n── 詳細 ──\n") print(s) }) # ───────────────────────────────────────── # SCATTER # ───────────────────────────────────────── output$scatter_checks <- renderUI({ d <- df() req(d) money_cols <- grep("売上|粗利|営業利益", colnames(d), value=TRUE) default_sel <- money_cols[seq_len(min(6, length(money_cols)))] choices <- setNames(money_cols, sapply(money_cols, short_label)) tagList( tags$div(class="check-grid", checkboxGroupInput("scatter_sel", NULL, choices = choices, selected = default_sel, inline = FALSE ) ) ) }) scatter_selected <- reactive({ req(input$scatter_sel) input$scatter_sel }) output$scatter_matrix <- renderPlot({ page_trigger() d <- df() req(d) sel <- tryCatch(scatter_selected(), error=function(e) NULL) if (is.null(sel) || length(sel) < 2) { par(bg="white", col.main="#0f172a") plot(1,1,type="n",xlab="",ylab="",main="2つ以上の変数を選択してください", col.main="#0f172a") return() } sd <- d %>% select(all_of(sel)) %>% select(where(is.numeric)) sd <- sd[complete.cases(sd), ] req(nrow(sd) >= 3) colnames(sd) <- sapply(colnames(sd), short_label) par(bg="white", col.main="#0f172a", col.axis="#475569", col.lab="#475569") pairs(sd, col.axis = "#475569", panel=function(x, y, ...) { points(x, y, col="#4fc3f7", cex=.7, pch=16) abline(lm(y~x), col="#f87171", lty=2, lwd=1.5) }, diag.panel=function(x, ...) { usr <- par("usr"); on.exit(par(usr)) par(usr=c(usr[1:2], 0, 1.5)) h <- hist(x, plot=FALSE) breaks <- h$breaks nB <- length(breaks) y <- h$counts; y <- y/max(y) rect(breaks[-nB], 0, breaks[-1], y, col="#34d399", border=NA) }, main="散布図行列", col.main="#0f172a") }, bg="white") # ───────────────────────────────────────── # STATS # ───────────────────────────────────────── make_stats <- function(dept_name, cols) { renderPrint({ d <- df() req(d) cat(dept_name, "の基本統計量\n") cat(paste(rep("─", 30), collapse=""), "\n\n") for (col in cols) { if (col %in% colnames(d)) { cat(short_label(col), ":\n") print(summary(d[[col]])) cat("\n") } } }) } output$stats_seibitsubi <- make_stats("整備部門", c( "整備_実績_売上_(単位:円)","整備_実績_原価_(単位:円)", "整備_実績_粗利_(単位:円)","整備_実績_営業利益_(単位:円)", "整備_KPI(一般車検件数)_実績_(単位:台)","整備_KPI(整備・板金数)_実績_(単位:台)" )) output$stats_shohin <- make_stats("商品部門", c( "商品_実績_売上_(単位:円)","商品_実績_原価_(単位:円)", "商品_実績_粗利_(単位:円)","商品_実績_営業利益_(単位:円)" )) output$stats_unsou <- make_stats("運送部門", c( "運送_実績_売上_(単位:円)","運送_実績_原価_(単位:円)", "運送_実績_粗利_(単位:円)","運送_実績_営業利益_(単位:円)", "運送_KPI(自社ドライバー数)_実績_(単位:台)","運送_KPI(他社ドライバー数)_実績_(単位:台)" )) output$stats_rental <- make_stats("レンタル部門", c( "レンタル_実績_売上_(単位:円)","レンタル_実績_原価_(単位:円)", "レンタル_実績_粗利_(単位:円)","レンタル_実績_営業利益_(単位:円)", "レンタル_KPI(レンタル台数)_実績_(単位:台)" )) # ───────────────────────────────────────── # REPORT # ───────────────────────────────────────── output$report_preview <- renderUI({ d <- df() if (is.null(d)) { return(tags$div(style="text-align:center;padding:40px;color:var(--text2)", "📊 データを読み込むとレポートが生成されます")) } latest <- tail(d, 1) period <- paste0(head(d$年月, 1), " 〜 ", tail(d$年月, 1)) months <- nrow(d) dept_rows <- list( list("整備", "整備_実績_売上_(単位:円)","整備_実績_粗利_(単位:円)","整備_実績_販管費_(単位:円)","整備_実績_営業利益_(単位:円)"), list("商品", "商品_実績_売上_(単位:円)","商品_実績_粗利_(単位:円)","商品_実績_販管費_(単位:円)","商品_実績_営業利益_(単位:円)"), list("運送", "運送_実績_売上_(単位:円)","運送_実績_粗利_(単位:円)","運送_実績_販管費_(単位:円)","運送_実績_営業利益_(単位:円)"), list("レンタル","レンタル_実績_売上_(単位:円)","レンタル_実績_粗利_(単位:円)","レンタル_実績_販管費_(単位:円)","レンタル_実績_営業利益_(単位:円)") ) total_s <- sum(sapply(dept_rows, function(r) as.numeric(latest[[r[[2]]]]) ), na.rm=TRUE) total_op <- sum(sapply(dept_rows, function(r) as.numeric(latest[[r[[5]]]]) ), na.rm=TRUE) op_margin <- if (!is.na(total_s) && total_s != 0) round(total_op/total_s*100, 1) else NA # avg profit trend avg_profit_cols <- sapply(dept_rows, function(r) r[[5]]) avg_profits <- sapply(avg_profit_cols, function(col) mean(d[[col]], na.rm=TRUE)) best_dept <- names(which.max(avg_profits)) best_dept_lbl <- gsub("_実績_営業利益.*","",best_dept) rpt_tbl_rows <- do.call(tagList, lapply(dept_rows, function(r) { s <- as.numeric(latest[[r[[2]]]]) g <- as.numeric(latest[[r[[3]]]]) sg <- as.numeric(latest[[r[[4]]]]) op <- as.numeric(latest[[r[[5]]]]) op_cls <- if (!is.na(op) && op < 0) "style='color:#dc2626'" else "style='color:#059669'" tags$tr( tags$td(r[[1]]), tags$td(fmt_yen(s)), tags$td(fmt_yen(g)), tags$td(fmt_yen(sg)), tags$td(HTML(paste0("", fmt_yen(op), ""))) ) })) tags$div(class="rpt-preview", tags$h1("経営指標 評価レポート"), tags$div(class="rpt-meta", paste0("対象期間: ", period, " (", months, "ヶ月) "), tags$br(), paste0("出力日時: ", format(Sys.time(), "%Y年%m月%d日 %H:%M")), if (is_demo()) tags$span(style="margin-left:8px;background:#fef3c7;color:#d97706;padding:1px 8px;border-radius:4px;font-size:10px;", "DEMO DATA") ), tags$div(class="rpt-kpi-row", tags$div(class="rpt-kpi", tags$div(class="rk-lbl","合計売上"), tags$div(class="rk-val", fmt_yen(total_s))), tags$div(class="rpt-kpi", tags$div(class="rk-lbl","合計営業利益"), tags$div(class="rk-val", fmt_yen(total_op))), tags$div(class="rpt-kpi", tags$div(class="rk-lbl","営業利益率"), tags$div(class="rk-val", if(!is.na(op_margin)) paste0(op_margin, "%") else "—")), tags$div(class="rpt-kpi", tags$div(class="rk-lbl","分析期間"), tags$div(class="rk-val", paste0(months, " ヶ月"))) ), tags$h2("直近月 部門別損益"), tags$table(class="rpt-tbl", tags$thead(tags$tr( tags$th("部門"), tags$th("売上"), tags$th("粗利"), tags$th("販管費"), tags$th("営業利益") )), tags$tbody(rpt_tbl_rows, tags$tr(class="tot", tags$td("合計"), tags$td(fmt_yen(total_s)), tags$td("—"), tags$td("—"), tags$td(fmt_yen(total_op)) ) ) ), tags$h2("KPI 直近月"), tags$table(class="rpt-tbl", tags$thead(tags$tr(tags$th("KPI指標"), tags$th("直近月値"))), tags$tbody( tags$tr(tags$td("車検件数"), tags$td(paste0(latest$`整備_KPI(一般車検件数)_実績_(単位:台)`, " 台"))), tags$tr(tags$td("整備・板金数"), tags$td(paste0(latest$`整備_KPI(整備・板金数)_実績_(単位:台)`, " 台"))), tags$tr(tags$td("自社ドライバー数"), tags$td(paste0(latest$`運送_KPI(自社ドライバー数)_実績_(単位:台)`, " 名"))), tags$tr(tags$td("他社ドライバー数"), tags$td(paste0(latest$`運送_KPI(他社ドライバー数)_実績_(単位:台)`, " 名"))), tags$tr(tags$td("レンタル台数"), tags$td(paste0(latest$`レンタル_KPI(レンタル台数)_実績_(単位:台)`, " 台"))) ) ), tags$h2("分析コメント"), tags$div(class="rpt-note", tags$b("■ 売上状況: "), paste0("直近月の全部門合計売上は ", fmt_yen(total_s), "。"), tags$br(), tags$b("■ 収益性: "), paste0("全部門合計営業利益は ", fmt_yen(total_op), "(営業利益率: ", if(!is.na(op_margin)) paste0(op_margin, "%") else "計算不可", ")。"), tags$br(), tags$b("■ 部門評価: "), paste0("分析期間中、平均営業利益が最も高い部門は ", best_dept_lbl, " 部門。"), tags$br(), tags$b("■ データ期間: "), paste0(period, "(計 ", months, "ヶ月)のデータを使用。") ) ) }) # ───────────────────────────────────────── # 非表示ページも常にレンダリング # (display:none のページでも描画を維持) # ───────────────────────────────────────── outputOptions(output, "ts_profit", suspendWhenHidden = FALSE) outputOptions(output, "ts_sales", suspendWhenHidden = FALSE) outputOptions(output, "ts_kpi", suspendWhenHidden = FALSE) outputOptions(output, "corr_heatmap", suspendWhenHidden = FALSE) outputOptions(output, "corr_table", suspendWhenHidden = FALSE) outputOptions(output, "reg_scatter", suspendWhenHidden = FALSE) outputOptions(output, "reg_results", suspendWhenHidden = FALSE) outputOptions(output, "scatter_matrix", suspendWhenHidden = FALSE) outputOptions(output, "scatter_checks", suspendWhenHidden = FALSE) outputOptions(output, "stats_seibitsubi", suspendWhenHidden = FALSE) outputOptions(output, "stats_shohin", suspendWhenHidden = FALSE) outputOptions(output, "stats_unsou", suspendWhenHidden = FALSE) outputOptions(output, "stats_rental", suspendWhenHidden = FALSE) outputOptions(output, "summary_ui", suspendWhenHidden = FALSE) outputOptions(output, "report_preview", suspendWhenHidden = FALSE) } shinyApp(ui = ui, server = server)