star back image
people4
電飾 電飾
moon
astronaut

【WEBアプリ】お見積書ジェネレーター(無料)

BLOG AIWEBアプリWEBログ
読了約:98分

私はむかし地元密着商社の営業マンで、3日に1〜2枚ペースで見積書を作成してました。そしてお客様にFAXを送ってました。
今っぽく「WEBアプリ」で、速攻で見積書を作って爆速でメールできないでしょうか。

AIさんに聞いてみました。

FAX!いま使ってる人いますかね。

エクセルで入力していました。印刷して、上司のハンコをもらってFAX。懐かしい‥あの時のハンコ、探せばあるかも。

エクセル、そして一太郎は流行でしたね。

見積書作成って、Excelを開いてテンプレートを探して…と意外と面倒ですよね。そこで、ブラウザを開くだけで一瞬で作れる専用ツールを作れないでしょうか。

[ 🏢 LOGO: 🔴 ] ------------------------------- [ 📤 OUTPUT (PDF/Excel/HTML): 🔴 ]
[ 📄 Client/Project Details: 🟠 ]

+-------------------------------------------------------------------------+
|  💼 営業費 (Sales & PM)                                 Subtotal: 🟠     |
|  [ ✍️ Task Name: 🔴 ]  [ 選択: 🟠 ]  [ 12,000: 🔴 ]  [ 8.0: 🔴 ]  [ 🗑️: 🔵 ]  |
|  [ ➕ Item Add: 🟠 ]                                                    |
+-------------------------------------------------------------------------+
|  🛠️ 作業費 (Production)                                 Subtotal: 🟠     |
|  [ ➕ Item Add: 🟠 ]                                                    |
+-------------------------------------------------------------------------+
|  📈 間接費 (Overhead)                                   Subtotal: 🟠     |
|  [ ➕ Item Add: 🟠 ]                                                    |
+-------------------------------------------------------------------------+

                                                       [ Total (Tax incl.): 🔴 ]

無料でお願いです。ニヤ(°∀° )ニヤ

💡 「完全無料の1ファイルWebアプリ」がExcelテンプレートに圧勝する理由
「エクセルを開いて、テンプレートを探して、フォントを整えて、計算式が壊れていないか怯えながら数字を打ち込む……」

そんな面倒な時間を、このアプリは一瞬でゼロにします。誰でも、登録不要で、完全に無料で使えるこのツールが、なぜエクセルより圧倒的に便利なのか。理由は3つだけです。

① 画面をそのまま「ポチポチ直す」だけで完成する
従来のように「入力フォームに文字を入れて、プレビュー画面で確認する」という面倒な行き来は一切ありません。画面に見えている見積書そのものが編集画面です。直したい文字や数字を直接クリックして書き換えるだけ。見たままの画面がそのまま見積書になります。

② 計算のズレや、数式の破損に怯えなくていい
エクセルでありがちな「うっかりセルを消して計算式が壊れた」「消費税の計算がズレた」というトラブルが絶対に起きません。数量や単価を書き換えた瞬間に、1ミリの遅延もなく全体の小計・消費税・合計金額が自動でピタッと再計算されます。

③ 面倒な「過去データの管理」が手元だけで完結する
高価なクラウドサービスや、怪しい会員登録は一切不要です。 編集したデータを「小さなメモ帳ファイル(JSON形式)」として手元のパソコンに無料でお手軽に保存しておくだけ。次に使うときは、そのファイルを画面にポイッと放り込む(ドラッグ&ドロップする)だけで、一瞬で過去のデータを画面に復元して、また続きから無料で何度でも編集できます。

これなら、誰でもブラウザを開くだけで、プロ級の美しい見積書が15秒で、しかもずっと無料で作れます!

ここから始まったのですね。

相談を重ね、プロンプトを作成してもらいました。

プロンプトを作成

やりたいことを削ぎ落とし、基本機能だけにしてCursorにコード作成をお願いします。

ブラウザ上でサクサク動き、そのままクライアントに提出できるレベルの美しい「見積書ジェネレーター」を開発したいです。
PHP、HTML、CSS、JavaScriptを使用し、データベース不要の「1ファイル(index.php)完結型」で動作するツールを作成してください。

【満たしたい要件】
1. 画面の直接編集(WYSIWYG)
- 画面上にA4サイズの見積書プレビューを表示し、それがそのまま編集画面になるようにしてください。
- 「社名」「件名」「金額」「明細」などのテキストを、クリックして直接書き換えられるようにしてください(contenteditableを使用)。
- 明細は「企画費」「デザイン費」などのグループ(セクション)ごとに分けられ、「行の追加・削除」「グループの追加・削除」ができるようにしてください。
- 小計、消費税(10%)、合計金額は、明細の数値を変えた瞬間にJavaScriptでリアルタイムに自動計算されるようにしてください。

2. ロゴ画像の動的変更
- 画面上のロゴエリアをクリック、またはドラッグ&ドロップで、ローカルの画像ファイルに差し替えられるようにしてください。
- アップロードされた画像はサーバーに保存せず、Base64データとしてブラウザのメモリ上に保持してください。

3. データベース不要の保存・復元(JSON)
- 現在編集中の見積もりデータ(差し替えたロゴ画像を含む)を「jsonファイル」としてローカルにダウンロード保存できるようにしてください。
- 画面上の「JSON読込」ボタンからそのファイルをアップロードすると、一瞬で編集状態が復元されるようにしてください。

4. 3つの形式での美しい出力(エクスポート)
- 【PDF出力】: ブラウザの印刷機能(@media print)を利用し、A4縦サイズに1ミリも崩れず美しく収まるようにしてください。印刷時は編集用のボタンや枠線を完全に非表示にしてください。
- 【Excel出力】: ExcelJSなどのCDNライブラリを使用し、セルの結合、列幅、フォント、罫線、金額フォーマット(¥#,##0)が設定された、そのまま提出できる美しい.xlsxファイルを出力してください。
- 【HTML出力】: CSSを内包した1ファイル完結型のHTMLとしてダウンロードできるようにしてください。

【デザインの要望】
- 無駄を削ぎ落とした、美しく洗練されたモダンなモノトーン(白基調にグレー・黒文字)のビジネス向けレイアウトにしてください。
- フォントはマルチデバイスで美しく見えるゴシック・サンセリフ系を指定し、文字間や余白のバランスを極限まで調整してください。

表示崩れをポチポチ直して完成です。

フォントとか変えられるようにしようと思いましたが、いらないです。
わたしでも使えるような「シンプルなもの」にしたい。

【共有】ソースコード

どこかの誰かの「あったらいいな」の役に立てたら幸いです。

🍀 見積書ジェネレーター
https://astrowave.jp/estimate/

astrowave.jp/estimate/
└── index.php

設置手順(3ステップ)
・自分のサーバーに estimate などの適当なフォルダを作ります。
・上記の index.php をアップロードします。

※エクセル出力のためにCDNを繋いでいます。(250行目)
https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js

※A4紙に印刷を想定(重視)です。

index.php

<?php /* 見積書ジェネレーター — astrowave.jp/estimate/ */ ?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>お見積書ジェネレーター</title>
<style>
/* ═══════ Reset & Base ═══════ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html{font-size:14px}
body{
  font-family:'Helvetica Neue',Arial,'Hiragino Kaku Gothic ProN','Noto Sans JP','Meiryo',sans-serif;
  background:#e8e8e8;color:#1a1a1a;line-height:1.6;-webkit-font-smoothing:antialiased;
}

/* ═══════ Toolbar ═══════ */
.toolbar{
  position:sticky;top:0;z-index:100;
  background:#fff;border-bottom:1px solid #ddd;
  padding:8px 20px;display:flex;align-items:center;justify-content:space-between;gap:10px;
  box-shadow:0 1px 3px rgba(0,0,0,.06);
}
.tb-title{font-size:13px;font-weight:700;color:#333;letter-spacing:.04em}
.tb-actions{display:flex;align-items:center;gap:5px;flex-wrap:wrap}
.tb-btn{
  display:inline-flex;align-items:center;gap:4px;
  padding:5px 11px;border:1px solid #ccc;border-radius:4px;
  background:#fff;color:#333;font-size:11.5px;font-family:inherit;
  cursor:pointer;transition:all .15s;white-space:nowrap;
}
.tb-btn:hover{background:#f5f5f5;border-color:#999}
.tb-btn.primary{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
.tb-btn.primary:hover{background:#333}
.tb-sep{width:1px;height:18px;background:#ddd;margin:0 3px}

/* ═══════ Page Container ═══════ */
.page-wrap{padding:28px;display:flex;justify-content:center;min-height:calc(100vh - 46px)}
.document{
  background:#fff;width:210mm;min-height:297mm;
  padding:16mm 18mm 14mm;
  box-shadow:0 2px 14px rgba(0,0,0,.08);position:relative;
}

/* ═══════ Document Header ═══════ */
.doc-date{font-size:10.5px;color:#1a1a1a;margin-bottom:20px}
.doc-logo{
  text-align:center;margin-bottom:12px;cursor:pointer;
  position:relative;padding:10px;
  border:2px dashed transparent;border-radius:8px;transition:border-color .2s;
}
.doc-logo:hover{border-color:#ccc}
.doc-logo.drag-over{border-color:#666;background:#f9f9f9}
.logo-text{font-size:30px;font-weight:800;letter-spacing:.14em;color:#1a1a1a;line-height:1.2;display:inline-block}
.logo-img{max-width:280px;max-height:65px;object-fit:contain}
.logo-hint{font-size:9.5px;color:#bbb;margin-top:2px}
.doc-title{text-align:center;font-size:18px;font-weight:700;letter-spacing:.14em;margin-bottom:24px}

/* ═══════ Meta ═══════ */
.doc-meta{
  display:flex;justify-content:space-between;align-items:flex-end;
  margin-bottom:4px;padding-bottom:3px;
}
.doc-client{font-size:14px;font-weight:500}
.doc-total-box{text-align:right}
.doc-total-label{font-size:9.5px;color:#1a1a1a;display:block}
.doc-total-value{font-size:24px;font-weight:800;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.doc-project{font-size:13px;font-weight:600;margin:14px 0 18px}

/* ═══════ Estimate Table ═══════ */
.est-table{width:100%;border-collapse:collapse;font-size:11.5px;table-layout:fixed}
.est-table col.c-name{width:48%}
.est-table col.c-price{width:14%}
.est-table col.c-qty{width:10%}
.est-table col.c-unit{width:10%}
.est-table col.c-amount{width:18%}

.est-table thead th{
  text-align:left;padding:5px 7px;
  border-top:2px solid #1a1a1a;border-bottom:1px solid #1a1a1a;
  font-weight:600;font-size:10.5px;color:#1a1a1a;white-space:nowrap;
}
.est-table th.ar{text-align:right}
.est-table th.ac{text-align:center}
.est-table th.col-name, .est-table td.col-name { padding-left: 0; }
.est-table th.col-amt, .est-table td.col-amt { padding-right: 0; }

.group-row td{padding:7px 7px 3px;font-weight:700;font-size:11.5px;border-bottom:none}
.group-row .g-name{display:inline}
.group-row .ar{text-align:right;font-variant-numeric:tabular-nums}

.item-row td{padding:4px 7px;border-bottom:1px solid #eee;vertical-align:middle}
.item-row td.ar{text-align:right;font-variant-numeric:tabular-nums}
.item-row td.ac{text-align:center}

.spacer-row td{padding:5px 0;border:none}
.add-row td{padding:3px 7px;border:none}

/* ═══════ Action Buttons ═══════ */
.btn-x{
  display:inline-flex;align-items:center;justify-content:center;
  width:18px;height:18px;border:none;border-radius:3px;
  background:transparent;color:#ccc;cursor:pointer;font-size:13px;padding:0;
  transition:all .15s;
}
.btn-x:hover{background:#fee;color:#e33}
.btn-add{
  display:inline-flex;align-items:center;gap:3px;
  padding:2px 8px;border:1px dashed #888;border-radius:3px;
  background:#fafafa;color:#555;font-size:10.5px;cursor:pointer;
  font-family:inherit;transition:all .15s;
}
.btn-add:hover{border-color:#ccc;color:#aaa;background:transparent}
.btn-add-group{margin-top:8px}

/* ═══════ Footer Section ═══════ */
.doc-footer-area{
  display:flex;justify-content:space-between;align-items:flex-start;gap:24px;margin-top:16px;
}
.doc-notes{flex:1}
.notes-heading{font-size:10.5px;font-weight:700;margin-bottom:3px}
.notes-body{
  font-size:10px;line-height:1.75;color:#1a1a1a;
  min-height:36px;padding:3px 4px;border-radius:3px;outline:none;
  white-space:pre-wrap;
}
.notes-body:focus{background:#f8f9fc}

.doc-totals{min-width:210px;font-size:11.5px}
.t-row{display:flex;justify-content:space-between;align-items:center;padding:3px 0}
.t-row .t-label{color:#1a1a1a;font-size:11px}
.t-row .t-val{font-weight:600;font-variant-numeric:tabular-nums}
.t-row.grand{border-top:2px solid #1a1a1a;margin-top:3px;padding-top:5px}
.t-row.grand .t-label{font-weight:700}
.t-row.grand .t-val{font-size:15px;font-weight:800}

.doc-company-footer{
  margin-top:36px;padding-top:10px;border-top:1px solid #ddd;
  font-size:9.5px;color:#1a1a1a;text-align:center;outline:none;
}
.doc-company-footer:focus{background:#f8f9fc}

/* ═══════ Contenteditable ═══════ */
[contenteditable]{outline:none;border-radius:2px;transition:background .15s}
[contenteditable]:hover{background:#f5f7fa}
[contenteditable]:focus{background:#eef2ff}

/* ═══════ Responsive ═══════ */
@media screen and (max-width:820px){
  .page-wrap{padding:8px}
  .document{width:100%;padding:24px 36px 24px 24px;min-height:auto}
  .toolbar{flex-wrap:wrap;padding:6px 10px}
  .tb-actions{flex-wrap:wrap}
  .doc-meta{flex-direction:column;align-items:flex-start;gap:8px}
  .doc-total-box{text-align:right;align-self:flex-end}
  .doc-footer-area{flex-direction:column;gap:16px}
  .doc-totals{width:100%;max-width:300px;order:-1;align-self:flex-end}
}

/* ═══════ Print ═══════ */
@media print{
  .no-print{display:none!important}
  body{background:#fff}
  .page-wrap{padding:0;min-height:auto}
  .document{box-shadow:none;width:auto;min-height:auto;padding:0}
  .doc-footer-area{page-break-inside:avoid}
  @page{size:A4;margin:14mm 17mm}
  [contenteditable]:hover,[contenteditable]:focus{background:transparent}
  .doc-logo{border-color:transparent!important;cursor:default}
  .doc-logo:hover{border-color:transparent!important}
  .logo-hint{display:none}
  .notes-body:focus{background:transparent}
  .doc-company-footer:focus{background:transparent}
}
</style>
</head>
<body>

<!-- ═══════ Toolbar ═══════ -->
<div class="toolbar no-print">
  <div class="tb-title">🍀 お見積書ジェネレーター</div>
  <div class="tb-actions">
    <button class="tb-btn primary" onclick="triggerJSONLoad()">📁 JSON読込</button>
    <button class="tb-btn primary" onclick="saveJSON()">💾 JSON保存</button>
    <span class="tb-sep"></span>
    <button class="tb-btn primary" onclick="exportPDF()">📄 PDF出力 / 🖨️ 印刷</button>
    <button class="tb-btn primary" onclick="exportExcel()">📊 Excel出力</button>
    <button class="tb-btn primary" onclick="exportHTML()">🌐 HTML出力</button>
  </div>
</div>

<!-- ═══════ Document ═══════ -->
<div class="page-wrap">
<div class="document" id="document">

  <div class="doc-date">御見積日 <span contenteditable="true" id="edDate"></span></div>

  <div class="doc-logo" id="logoArea">
    <img class="logo-img" id="logoImg" style="display:none" alt="Logo">
    <span class="logo-text" contenteditable="true" id="logoText"></span>
    <div class="logo-hint no-print">クリックでロゴ画像を変更 / ドラッグ&ドロップ</div>
  </div>

  <div class="doc-title" contenteditable="true" id="edTitle"></div>

  <div class="doc-meta">
    <div class="doc-client"><span contenteditable="true" id="edClient"></span> 御中</div>
    <div class="doc-total-box">
      <span class="doc-total-label">御見積書小計金額(税抜)</span>
      <span class="doc-total-value" id="dispTotal"></span>
    </div>
  </div>

  <div class="doc-project" contenteditable="true" id="edProject"></div>

  <table class="est-table" id="estTable">
    <colgroup>
      <col class="c-name"><col class="c-price"><col class="c-qty">
      <col class="c-unit"><col class="c-amount">
    </colgroup>
    <thead>
      <tr>
        <th class="col-name">内容</th><th class="ar">単価</th><th class="ac">数量</th>
        <th class="ac">単位</th><th class="ar col-amt">金額</th>
      </tr>
    </thead>
    <tbody id="tBody"></tbody>
  </table>

  <button class="btn-add btn-add-group no-print" onclick="addGroup()">+ グループを追加</button>

  <div class="doc-footer-area">
    <div class="doc-notes">
      <div class="notes-heading">【特記事項】</div>
      <div class="notes-body" contenteditable="true" id="edNotes"></div>
    </div>
    <div class="doc-totals" id="totalsArea"></div>
  </div>

  <div class="doc-company-footer" contenteditable="true" id="edFooter"></div>
</div>
</div>

<!-- Hidden inputs -->
<input type="file" id="jsonFileInput" accept=".json" style="display:none">
<input type="file" id="logoFileInput" accept="image/*" style="display:none">

<!-- CDN: ExcelJS -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js"></script>

<script>
(function(){
'use strict';

/* ═══════════════════════════════════
   State
   ═══════════════════════════════════ */
const DEFAULT_STATE = {
  date: new Date().toLocaleDateString('ja-JP',{year:'numeric',month:'long',day:'numeric'}),
  logo: null,
  companyName: 'YOUR COMPANY',
  title: '概算御見積書',
  clientName: '[取引先名]',
  projectName: '[案件名]',
  taxRate: 10,
  groups: [
    { name:'企画費', items:[
      {name:'企画・要件定義費', price:12000, qty:8, unit:'時間'},
      {name:'プロダクションマネジメント費', price:10000, qty:8, unit:'時間'},
    ]},
    { name:'デザイン費(PC/SP)', items:[
      {name:'LPデザイン費(PC/SP)', price:10000, qty:24, unit:'時間'},
      {name:'アートディレクション費', price:12000, qty:4, unit:'時間'},
    ]},
    { name:'コーディング費(PC/SP)', items:[
      {name:'LPコーディング費(PC/SP)', price:10000, qty:16, unit:'時間'},
      {name:'検証・修正対応費', price:10000, qty:4, unit:'時間'},
    ]},
    { name:'間接費', items:[
      {name:'間接費 (全体の10%にて計上)', price:0, qty:10, unit:'%'},
    ]},
  ],
  notes: '・LP1ページ(PC/SP)制作を想定した金額です\n・撮影・原稿・画像素材の準備は貴社ご対応を想定しています\n・ページ数やデザイン修正回数の変更に応じて金額は変動する可能性があります',
  footerText: '株式会社YOUR COMPANY 〒000-0000 東京都○○区○○ 0-0-0 TEL:00-0000-0000 担当:○○ ○○',
};

let S = JSON.parse(JSON.stringify(DEFAULT_STATE));

/* ═══════════════════════════════════
   Utilities
   ═══════════════════════════════════ */
function esc(s){ const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
function fmtYen(n){ return '¥'+Math.round(n).toLocaleString('ja-JP'); }
function fmtNum(n){ return n ? '¥'+Math.round(n).toLocaleString('ja-JP') : ''; }
function fmtQty(n){ return (typeof n==='number') ? n.toFixed(1) : '0.0'; }
function parseNum(s){ return parseInt(String(s).replace(/[^0-9\-]/g,''),10)||0; }
function parseQty(s){ return parseFloat(String(s).replace(/[^0-9.\-]/g,''))||0; }
function $(id){ return document.getElementById(id); }

/* ═══════════════════════════════════
   Calculation
   ═══════════════════════════════════ */
function recalculate(){
  let directTotal = 0;
  S.groups.forEach(g => {
    g.items.forEach(it => {
      if(it.unit !== '%') directTotal += it.price * it.qty;
    });
  });

  S.groups.forEach(g => {
    g._sub = 0;
    g.items.forEach(it => {
      it._amt = (it.unit === '%') ? Math.round(directTotal * it.qty / 100) : Math.round(it.price * it.qty);
      g._sub += it._amt;
    });
  });

  S._subtotal = S.groups.reduce((s,g) => s + g._sub, 0);
  S._tax = Math.round(S._subtotal * S.taxRate / 100);
  S._total = S._subtotal + S._tax;
}

/* ═══════════════════════════════════
   Rendering
   ═══════════════════════════════════ */
function renderAll(){
  recalculate();

  $('edDate').textContent = S.date;
  $('edTitle').textContent = S.title;
  $('edClient').textContent = S.clientName;
  $('edProject').textContent = S.projectName;
  $('edNotes').textContent = S.notes;
  $('edFooter').textContent = S.footerText;

  if(S.logo){
    $('logoImg').src = S.logo;
    $('logoImg').style.display = '';
    $('logoText').style.display = 'none';
  } else {
    $('logoImg').style.display = 'none';
    $('logoText').style.display = '';
    $('logoText').textContent = S.companyName;
  }

  renderTable();
  renderTotals();
  $('dispTotal').textContent = fmtYen(S._subtotal);
}

function renderTable(){
  let h = '';
  S.groups.forEach((g,gi) => {
    h += '<tr class="group-row" data-gi="'+gi+'">'
      +  '<td colspan="4" class="col-name"><span class="g-name" contenteditable="true" data-f="gname" data-gi="'+gi+'">'+esc(g.name)+' 内訳</span></td>'
      +  '<td class="ar col-amt" style="position:relative;"><span class="g-sub">'+fmtYen(g._sub)+'</span>'
      +  '<button class="btn-x no-print" style="position:absolute;right:-24px;top:50%;transform:translateY(-50%);" onclick="removeGroup('+gi+')" title="グループ削除">×</button></td></tr>';

    g.items.forEach((it,ii) => {
      const isP = it.unit === '%';
      h += '<tr class="item-row" data-gi="'+gi+'" data-ii="'+ii+'">'
        +  '<td class="col-name"><span contenteditable="true" data-f="name" data-gi="'+gi+'" data-ii="'+ii+'">'+esc(it.name)+'</span></td>'
        +  '<td class="ar" contenteditable="true" data-f="price" data-gi="'+gi+'" data-ii="'+ii+'">'+(isP?'':fmtNum(it.price))+'</td>'
        +  '<td class="ac" contenteditable="true" data-f="qty" data-gi="'+gi+'" data-ii="'+ii+'">'+fmtQty(it.qty)+'</td>'
        +  '<td class="ac" contenteditable="true" data-f="unit" data-gi="'+gi+'" data-ii="'+ii+'">'+esc(it.unit)+'</td>'
        +  '<td class="ar item-amt col-amt" style="position:relative;" data-gi="'+gi+'" data-ii="'+ii+'"><span class="i-amt">'+fmtYen(it._amt)+'</span>'
        +  '<button class="btn-x no-print" style="position:absolute;right:-24px;top:50%;transform:translateY(-50%);" onclick="removeItem('+gi+','+ii+')" title="行を削除">×</button></td>'
        +  '</tr>';
    });

    h += '<tr class="add-row no-print"><td colspan="5"><button class="btn-add" onclick="addItem('+gi+')">+ 行を追加</button></td></tr>';
    h += '<tr class="spacer-row"><td colspan="5"></td></tr>';
  });

  $('tBody').innerHTML = h;
  attachCellListeners();
}

function renderTotals(){
  const area = $('totalsArea');
  area.innerHTML =
    '<div class="t-row"><span class="t-label">小計</span><span class="t-val" id="vSub">'+fmtYen(S._subtotal)+'</span></div>'
   +'<div class="t-row"><span class="t-label">消費税('+S.taxRate+'%)</span><span class="t-val" id="vTax">'+fmtYen(S._tax)+'</span></div>'
   +'<div class="t-row grand"><span class="t-label">合計</span><span class="t-val" id="vTotal">'+fmtYen(S._total)+'</span></div>';
}

function refreshCalc(){
  recalculate();

  document.querySelectorAll('.group-row').forEach(tr => {
    const gi = +tr.dataset.gi;
    const subEl = tr.querySelector('.g-sub');
    if(subEl) subEl.textContent = fmtYen(S.groups[gi]._sub);
  });
  document.querySelectorAll('.item-amt').forEach(td => {
    const gi = +td.dataset.gi, ii = +td.dataset.ii;
    const amtEl = td.querySelector('.i-amt');
    if(amtEl) amtEl.textContent = fmtYen(S.groups[gi].items[ii]._amt);
  });

  $('dispTotal').textContent = fmtYen(S._subtotal);
  renderTotals();
}

/* ═══════════════════════════════════
   Cell Editing
   ═══════════════════════════════════ */
function attachCellListeners(){
  $('tBody').querySelectorAll('[contenteditable]').forEach(el => {
    el.addEventListener('blur', onCellBlur);
    el.addEventListener('keydown', e => { if(e.key==='Enter'){e.preventDefault();el.blur();} });
  });
}

function onCellBlur(e){
  const el = e.target;
  const f = el.dataset.f, gi = +el.dataset.gi, ii = el.dataset.ii;

  if(f === 'gname'){
    S.groups[gi].name = el.textContent.replace(/[\s ]*内訳$/,'').trim();
    el.textContent = S.groups[gi].name + ' 内訳';
  } else if(ii !== undefined){
    const it = S.groups[gi].items[+ii];
    switch(f){
      case 'name': it.name = el.textContent.trim(); break;
      case 'price':
        it.price = parseNum(el.textContent);
        el.textContent = it.unit==='%' ? '' : fmtNum(it.price);
        break;
      case 'qty':
        it.qty = parseQty(el.textContent);
        el.textContent = fmtQty(it.qty);
        break;
      case 'unit':
        it.unit = el.textContent.trim();
        break;
    }
  }
  refreshCalc();
}

/* Header field sync */
$('edDate').addEventListener('blur', ()=>{ S.date = $('edDate').textContent.trim(); });
$('edTitle').addEventListener('blur', ()=>{ S.title = $('edTitle').textContent.trim(); });
$('edClient').addEventListener('blur', ()=>{ S.clientName = $('edClient').textContent.trim(); });
$('edProject').addEventListener('blur', ()=>{ S.projectName = $('edProject').textContent.trim(); });
$('edNotes').addEventListener('blur', ()=>{ S.notes = $('edNotes').innerText.trim(); });
$('edFooter').addEventListener('blur', ()=>{ S.footerText = $('edFooter').textContent.trim(); });
$('logoText').addEventListener('blur', ()=>{ if(!S.logo) S.companyName = $('logoText').textContent.trim(); });

/* ═══════════════════════════════════
   Structural Edit (add / remove)
   ═══════════════════════════════════ */
window.addGroup = function(){
  S.groups.push({ name:'新規セクション', items:[{name:'[項目名]', price:0, qty:1, unit:'時間'}] });
  recalculate(); renderTable(); refreshCalc();
};
window.removeGroup = function(gi){
  if(!confirm('「'+S.groups[gi].name+'」グループを削除しますか?')) return;
  S.groups.splice(gi,1);
  recalculate(); renderTable(); refreshCalc();
};
window.addItem = function(gi){
  S.groups[gi].items.push({name:'[項目名]', price:0, qty:1, unit:'時間'});
  recalculate(); renderTable(); refreshCalc();
};
window.removeItem = function(gi,ii){
  S.groups[gi].items.splice(ii,1);
  recalculate(); renderTable(); refreshCalc();
};

/* ═══════════════════════════════════
   Logo Upload
   ═══════════════════════════════════ */
const logoArea = $('logoArea');
const logoInput = $('logoFileInput');

logoArea.addEventListener('click', e => {
  if(e.target.closest('[contenteditable]') && !S.logo) return;
  logoInput.click();
});
logoInput.addEventListener('change', e => { if(e.target.files[0]) loadLogoFile(e.target.files[0]); });

logoArea.addEventListener('dragover', e => { e.preventDefault(); logoArea.classList.add('drag-over'); });
logoArea.addEventListener('dragleave', () => { logoArea.classList.remove('drag-over'); });
logoArea.addEventListener('drop', e => {
  e.preventDefault(); logoArea.classList.remove('drag-over');
  const f = e.dataTransfer.files[0];
  if(f && f.type.startsWith('image/')) loadLogoFile(f);
});

function loadLogoFile(file){
  const reader = new FileReader();
  reader.onload = e => {
    S.logo = e.target.result;
    $('logoImg').src = S.logo;
    $('logoImg').style.display = '';
    $('logoText').style.display = 'none';
  };
  reader.readAsDataURL(file);
}

/* ═══════════════════════════════════
   JSON Save / Load
   ═══════════════════════════════════ */
window.saveJSON = function(){
  syncHeaderFields();
  const clean = JSON.parse(JSON.stringify(S));
  clean.groups.forEach(g => { delete g._sub; g.items.forEach(it => delete it._amt); });
  delete clean._subtotal; delete clean._tax; delete clean._total;

  const blob = new Blob([JSON.stringify(clean, null, 2)], {type:'application/json'});
  dl(blob, 'estimate_'+dateSlug()+'.json');
};

window.triggerJSONLoad = function(){ $('jsonFileInput').click(); };
$('jsonFileInput').addEventListener('change', e => {
  const f = e.target.files[0]; if(!f) return;
  const reader = new FileReader();
  reader.onload = ev => {
    try {
      const data = JSON.parse(ev.target.result);
      Object.assign(S, data);
      renderAll();
    } catch(err){ alert('JSONファイルの読み込みに失敗しました。'); }
  };
  reader.readAsText(f);
  e.target.value = '';
});

/* ═══════════════════════════════════
   PDF Export (Print)
   ═══════════════════════════════════ */
window.exportPDF = function(){
  syncHeaderFields();
  window.print();
};

/* ═══════════════════════════════════
   Excel Export
   ═══════════════════════════════════ */
window.exportExcel = async function(){
  if(typeof ExcelJS === 'undefined'){ alert('Excelライブラリを読み込めませんでした。'); return; }
  syncHeaderFields(); recalculate();

  const wb = new ExcelJS.Workbook();
  const ws = wb.addWorksheet('見積書');

  ws.columns = [
    { width: 42 }, // A: 内容
    { width: 14 }, // B: 単価
    { width: 10 }, // C: 数量
    { width: 10 }, // D: 単位
    { width: 18 }  // E: 金額
  ];

  ws.eachRow((row) => { row.font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } }; });

  let r = 1;

  // Row 1: Date
  ws.getCell(`A${r}`).value = '御見積日 ' + S.date;
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
  r += 2;

  // Row 3: Logo or Company Name
  if (S.logo) {
    const imgEl = $('logoImg');
    const nw = imgEl.naturalWidth || 240;
    const nh = imgEl.naturalHeight || 60;
    const maxW = 280;
    const maxH = 65;
    const scale = Math.min(maxW / nw, maxH / nh);
    const finalW = Math.round(nw * scale);
    const finalH = Math.round(nh * scale);

    // Apple Numbers等の互換性のため、透明な余白を持つ横幅いっぱいの画像(Canvas)を生成して中央寄せする
    const canvasW = 705; // A〜E列の合計ピクセル幅
    const canvasH = 75;  // 高さ(4行分)

    const paddedLogo = await new Promise(resolve => {
      const img = new Image();
      img.onload = () => {
        const cvs = document.createElement('canvas');
        cvs.width = canvasW;
        cvs.height = canvasH;
        const ctx = cvs.getContext('2d');
        const dx = (canvasW - finalW) / 2;
        const dy = (canvasH - finalH) / 2;
        ctx.drawImage(img, dx, dy, finalW, finalH);
        resolve(cvs.toDataURL('image/png'));
      };
      img.src = S.logo;
    });

    const imageId = wb.addImage({
      base64: paddedLogo.split(',')[1],
      extension: 'png',
    });

    ws.mergeCells(`A${r}:E${r+3}`);
    ws.addImage(imageId, {
      tl: { col: 0, row: r - 1 }, // 小数点を使わずA列の左端にピッタリ配置
      ext: { width: canvasW, height: canvasH }
    });
  } else {
    ws.mergeCells(`A${r}:E${r+3}`);
    const cell = ws.getCell(`A${r}`);
    cell.value = S.companyName;
    cell.font = { name: 'Meiryo', size: 18, bold: true, color: { argb: 'FF1A1A1A' } };
    cell.alignment = { horizontal: 'center', vertical: 'middle' };
  }
  r += 4;

  // Title
  ws.mergeCells(`A${r}:E${r}`);
  const titleCell = ws.getCell(`A${r}`);
  titleCell.value = S.title;
  titleCell.font = { name: 'Meiryo', size: 16, bold: true, color: { argb: 'FF1A1A1A' } };
  titleCell.alignment = { horizontal: 'center', vertical: 'middle' };
  r += 2;

  // Client & Subtotal
  ws.getCell(`A${r}`).value = S.clientName + ' 御中';
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 12, bold: true, color: { argb: 'FF1A1A1A' } };

  ws.getCell(`D${r}`).value = '御見積書小計金額(税抜)';
  ws.getCell(`D${r}`).font = { name: 'Meiryo', size: 9, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`D${r}`).alignment = { horizontal: 'right', vertical: 'bottom' };

  ws.getCell(`E${r}`).value = S._subtotal;
  ws.getCell(`E${r}`).numFmt = '"¥"#,##0';
  ws.getCell(`E${r}`).font = { name: 'Meiryo', size: 16, bold: true, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).alignment = { horizontal: 'right', vertical: 'bottom' };

  r += 2;

  // Project Name
  ws.getCell(`A${r}`).value = S.projectName;
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 11, bold: true, color: { argb: 'FF1A1A1A' } };
  r += 2;

  // Table Header
  const headers = ['内容', '単価', '数量', '単位', '金額'];
  headers.forEach((h, i) => {
    const cell = ws.getCell(r, i+1);
    cell.value = h;
    cell.font = { name: 'Meiryo', size: 10, bold: true, color: { argb: 'FF1A1A1A' } };
    cell.border = { top: { style: 'medium' }, bottom: { style: 'thin' } };
    if (i === 1 || i === 4) cell.alignment = { horizontal: 'right' };
    if (i === 2 || i === 3) cell.alignment = { horizontal: 'center' };
  });
  r += 1;

  // Table Body
  S.groups.forEach(g => {
    ws.mergeCells(`A${r}:D${r}`);
    const gCell = ws.getCell(`A${r}`);
    gCell.value = g.name + ' 内訳';
    gCell.font = { name: 'Meiryo', size: 10, bold: true, color: { argb: 'FF1A1A1A' } };

    const gSubCell = ws.getCell(`E${r}`);
    gSubCell.value = g._sub;
    gSubCell.numFmt = '"¥"#,##0';
    gSubCell.font = { name: 'Meiryo', size: 10, bold: true, color: { argb: 'FF1A1A1A' } };
    r += 1;

    g.items.forEach(it => {
      ws.getCell(`A${r}`).value = ' ' + it.name;
      ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };

      if (it.unit !== '%') {
        ws.getCell(`B${r}`).value = it.price;
        ws.getCell(`B${r}`).numFmt = '"¥"#,##0';
      }

      ws.getCell(`C${r}`).value = it.qty;
      ws.getCell(`C${r}`).numFmt = '0.0';
      ws.getCell(`C${r}`).alignment = { horizontal: 'center' };

      ws.getCell(`D${r}`).value = it.unit;
      ws.getCell(`D${r}`).alignment = { horizontal: 'center' };

      ws.getCell(`E${r}`).value = it._amt;
      ws.getCell(`E${r}`).numFmt = '"¥"#,##0';

      for(let c=1; c<=5; c++){
        ws.getCell(r, c).border = { bottom: { style: 'hair', color: { argb: 'FFEEEEEE' } } };
      }
      r += 1;
    });
    r += 1; // spacer
  });

  // Totals and Notes
  const notesArr = S.notes.split('\n');
  
  ws.getCell(`A${r}`).value = '【特記事項】';
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, bold: true, color: { argb: 'FF1A1A1A' } };

  ws.getCell(`D${r}`).value = '小計';
  ws.getCell(`D${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).value = S._subtotal;
  ws.getCell(`E${r}`).numFmt = '"¥"#,##0';
  ws.getCell(`E${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).alignment = { horizontal: 'right' };
  r += 1;

  ws.getCell(`A${r}`).value = notesArr[0] || '';
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };

  ws.getCell(`D${r}`).value = '消費税('+S.taxRate+'%)';
  ws.getCell(`D${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).value = S._tax;
  ws.getCell(`E${r}`).numFmt = '"¥"#,##0';
  ws.getCell(`E${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).alignment = { horizontal: 'right' };
  r += 1;

  ws.getCell(`A${r}`).value = notesArr[1] || '';
  ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };

  ws.getCell(`D${r}`).value = '合計';
  ws.getCell(`D${r}`).font = { name: 'Meiryo', size: 11, bold: true, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`D${r}`).border = { top: { style: 'medium', color: { argb: 'FF1A1A1A' } } };
  ws.getCell(`E${r}`).value = S._total;
  ws.getCell(`E${r}`).numFmt = '"¥"#,##0';
  ws.getCell(`E${r}`).font = { name: 'Meiryo', size: 12, bold: true, color: { argb: 'FF1A1A1A' } };
  ws.getCell(`E${r}`).border = { top: { style: 'medium', color: { argb: 'FF1A1A1A' } } };
  ws.getCell(`E${r}`).alignment = { horizontal: 'right' };
  r += 1;

  for(let i=2; i<notesArr.length; i++){
    ws.getCell(`A${r}`).value = notesArr[i];
    ws.getCell(`A${r}`).font = { name: 'Meiryo', size: 10, color: { argb: 'FF1A1A1A' } };
    r += 1;
  }

  r += 2;
  ws.mergeCells(`A${r}:E${r}`);
  const footCell = ws.getCell(`A${r}`);
  footCell.value = S.footerText;
  footCell.font = { name: 'Meiryo', size: 9, color: { argb: 'FF1A1A1A' } };
  footCell.alignment = { horizontal: 'center' };
  footCell.border = { top: { style: 'thin', color: { argb: 'FFDDDDDD' } } };

  // Generate file
  const buffer = await wb.xlsx.writeBuffer();
  const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  dl(blob, 'estimate_'+dateSlug()+'.xlsx');
};

/* ═══════════════════════════════════
   HTML Export
   ═══════════════════════════════════ */
window.exportHTML = function(){
  syncHeaderFields(); recalculate();

  let css = document.querySelector('style').textContent;
  css = css.replace(/@media\s+print\s*\{[\s\S]*$/, '');

  const clone = $('document').cloneNode(true);
  clone.querySelectorAll('.no-print, .btn-x, .btn-add, .btn-add-group, .add-row').forEach(el => el.remove());
  clone.querySelectorAll('[contenteditable]').forEach(el => el.removeAttribute('contenteditable'));
  clone.querySelector('.doc-logo').style.cursor = 'default';
  clone.querySelector('.doc-logo').style.borderColor = 'transparent';
  const hint = clone.querySelector('.logo-hint');
  if(hint) hint.remove();

  const html = '<!DOCTYPE html>\n<html lang="ja">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>'+esc(S.title)+' — '+esc(S.clientName)+'</title>\n<style>\n'
    + css
    + '\nbody{background:#fff}\n.page-wrap{padding:0}\n.document{box-shadow:none;margin:0 auto;padding:16mm 18mm}\n[contenteditable]:hover,[contenteditable]:focus{background:transparent}\n'
    + '</style>\n</head>\n<body>\n<div class="page-wrap">\n'
    + clone.outerHTML
    + '\n</div>\n</body>\n</html>';

  dl(new Blob([html],{type:'text/html;charset=UTF-8'}), 'estimate_'+dateSlug()+'.html');
};

/* ═══════════════════════════════════
   Helpers
   ═══════════════════════════════════ */
function syncHeaderFields(){
  S.date = $('edDate').textContent.trim();
  S.title = $('edTitle').textContent.trim();
  S.clientName = $('edClient').textContent.trim();
  S.projectName = $('edProject').textContent.trim();
  S.notes = $('edNotes').innerText.trim();
  S.footerText = $('edFooter').textContent.trim();
  if(!S.logo) S.companyName = $('logoText').textContent.trim();
}

function dl(blob, name){
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = name;
  document.body.appendChild(a); a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

function dateSlug(){
  const d = new Date();
  return d.getFullYear()+('0'+(d.getMonth()+1)).slice(-2)+('0'+d.getDate()).slice(-2);
}

/* ═══════════════════════════════════
   Init
   ═══════════════════════════════════ */
renderAll();

})();
</script>
</body>
</html>

コードの詳しくはチャッピーやジェミニに放り込んで聞いてください。
改造をするのも楽しいかも。

何か、すごくいい考えがありましたら、教えてください。

【AI】イラストを描いてもらった

Flow」で作成した画像です。誰でもgoogleアカウントでログインして使えます。

たたかうビジネスマンの味方!

商売繁盛!無病息災!ゴリ夢中!

星間旅路のメロディ

「宇宙の静けさに包まれながら、漂流する過去の音楽を捜し求め、銀河の奥底でその旋律に耳を傾ける。」

「この電波はどこの星からきたのだろうか。」

どこかで聞いたことがあるような。