sugitora's picture
Upload 6 files
53968e5 verified
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)