Spaces:
Sleeping
Sleeping
| 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("<span ", op_cls, "><b>", fmt_yen(op), "</b></span>"))) | |
| ) | |
| })) | |
| 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) | |