ReoGrid ReoGrid Web

編集可能な数式付き Vue 製インボイスを約 60 行で構築する

· unvell team
編集可能な数式付き Vue 製インボイスを約 60 行で構築する

「インボイス」は小さなスプレッドシートのユースケースの代表例です。ヘッダー、明細行(数量 × 単価)、小計、税、合計。Vue なら <table>computed プロパティを大量に並べて算術を処理し、別途「Excel としてダウンロード」ボタンで素っ気ない CSV を吐かせる、といった作りになりがちです。

これを本物のスプレッドシートで — 編集可能、数式駆動、メソッド呼び出し 1 回でダウンロード可能な .xlsx を生成できる形で — 1 枚の Vue 3 SFC として書くと、こうなります。

<script setup lang="ts">
import { Reogrid, type ReogridInstance } from '@reogrid/pro/vue';

const items: Array<[string, number, number]> = [
  ['Web システム開発(コア)',     1, 200_000],
  ['追加: API 連携',              2,  30_000],
  ['テスト&QA',                  1,  20_000],
  ['ドキュメント',                1,  10_000],
  ['プロジェクトマネジメント',    1,  10_000],
];

function onReady({ worksheet: ws }: ReogridInstance) {
  ws.setGridSize(25, 5);
  ws.showGridLines = false;

  // タイトルバー
  ws.range('A1:E1').merge().setValue('請求書')
    .setStyle({ fontSize: 18, bold: true, textAlign: 'center', backgroundColor: '#1e3a5f', color: '#ffffff' });
  ws.row(0).height = 48;

  // メタ情報
  ws.cell('A3').setValue('請求先:').setStyle({ bold: true });
  ws.cell('B3').setValue('株式会社 Acme 山田様');
  ws.cell('D3').setValue('日付:').setStyle({ textAlign: 'right', color: '#64748b' });
  ws.cell('E3').setValue('2026-05-29');
  ws.cell('D4').setValue('請求書番号:').setStyle({ textAlign: 'right', color: '#64748b' });
  ws.cell('E4').setValue('INV-2026-001');

  // 表のヘッダー
  const hdr = { bold: true, backgroundColor: '#1e3a5f', color: '#ffffff' };
  ws.cell('A7').setValue('No.').setStyle({ ...hdr, textAlign: 'center' });
  ws.cell('B7').setValue('品目').setStyle(hdr);
  ws.cell('C7').setValue('数量').setStyle({ ...hdr, textAlign: 'center' });
  ws.cell('D7').setValue('単価').setStyle({ ...hdr, textAlign: 'right' });
  ws.cell('E7').setValue('金額').setStyle({ ...hdr, textAlign: 'right' });

  // 明細行 — 数量と単価は編集可能、金額は数式
  items.forEach(([name, qty, price], i) => {
    const row = 8 + i;                       // A1 表記の 1 始まり行番号
    const editable = { backgroundColor: '#fffbeb', color: '#92400e', bold: true };

    ws.cell(`A${row}`).setValue(String(i + 1)).setStyle({ textAlign: 'center' });
    ws.cell(`B${row}`).setValue(name);
    ws.setCellInput(row - 1, 2, String(qty));          ws.setCellStyle(row - 1, 2, { ...editable, textAlign: 'center' });
    ws.setCellInput(row - 1, 3, String(price));        ws.setCellStyle(row - 1, 3, { ...editable, textAlign: 'right' });
    ws.setCellNumberFormat(row - 1, 3, '#,##0');
    ws.setCellInput(row - 1, 4, `=C${row}*D${row}`);   // 金額の数式
    ws.setCellNumberFormat(row - 1, 4, '#,##0');
  });

  const lastItemRow = 8 + items.length - 1;
  const sumRow = lastItemRow + 1, taxRow = sumRow + 1, totalRow = taxRow + 1;

  // 小計 / 税 / 合計 — すべて数式
  ws.range(`A${sumRow}:D${sumRow}`).merge();
  ws.cell(`A${sumRow}`).setValue('小計').setStyle({ textAlign: 'right', backgroundColor: '#f0f4f8' });
  ws.setCellInput(sumRow - 1, 4, `=SUM(E8:E${lastItemRow})`);
  ws.setCellNumberFormat(sumRow - 1, 4, '#,##0');

  ws.range(`A${taxRow}:D${taxRow}`).merge();
  ws.cell(`A${taxRow}`).setValue('消費税 (10%)').setStyle({ textAlign: 'right', backgroundColor: '#f0f4f8' });
  ws.setCellInput(taxRow - 1, 4, `=E${sumRow}*0.1`);
  ws.setCellNumberFormat(taxRow - 1, 4, '#,##0');

  ws.range(`A${totalRow}:D${totalRow}`).merge();
  ws.cell(`A${totalRow}`).setValue('税込合計').setStyle({ bold: true, textAlign: 'right', backgroundColor: '#dbeafe' });
  ws.setCellInput(totalRow - 1, 4, `=E${sumRow}+E${taxRow}`);
  ws.setCellNumberFormat(totalRow - 1, 4, '¥#,##0');
  ws.setCellStyle(totalRow - 1, 4, { bold: true, textAlign: 'right', backgroundColor: '#dbeafe', color: '#1e3a5f', fontSize: 13 });

  // 表に罫線
  ws.range(`A7:E${totalRow}`).border({ style: 'solid', color: '#475569', width: 1.5 });
}
</script>

<template>
  <Reogrid
    :options="{ animation: true }"
    style="width: 100%; height: 700px"
    @ready="onReady"
  />
</template>

これでコンポーネントは完成です。ユーザーは数量や単価のセルをクリックして新しい値を入力し、Enter を押せば、金額・小計・税・合計が即座に再計算されます。

本サイトの インボイスデモ は実質これと同じコードで動いています — ぜひライブで触ってみてください。

React 版から来た方は、onReady の中身が 1 バイトたりとも変わっていないことに気づくはずです。グリッド API はフレームワークをまたいで共通で、違うのはラッパーだけ — onReady@readyoptions:options、そして React の ref が Vue の ref になっているだけです。


このコードが成り立つ 3 つのポイント

1. setCellInputsetValue の違い

最もよくハマる落とし穴です。セルにリテラル値を入れるには次のようにします。

worksheet.cell('A1').setValue('Hello');   // 文字列 "Hello"
worksheet.cell('B1').setValue(42);        // 数値 42
worksheet.cell('C1').setValue('=A1+B1');  // ← 文字列 "=A1+B1" — 数式ではありません!

セルに数式を入れたい場合は setCellInput を使います。これはユーザーがセルに入力するのと同じコードパスを通ります。

worksheet.setCellInput(0, 2, '=A1+B1');   // 数式、依存セル変更ごとに再評価

逆方向も同じです。cell('C1').value を読むと 評価後の値1+41 の場合は数値 42)が返り、数式文字列は返りません。数式ソースが欲しい場合は cell('C1').input を使います。

2. 表示形式は Excel 互換

setCellNumberFormat(row, col, '#,##0') は Excel と同じ書式パターン構文を受け付けます。インボイスでよく使うものをいくつか挙げます。

パターン表示備考
#,##0200,000桁区切りあり、小数なし
#,##0.00200,000.00小数 2 桁
¥#,##0¥200,000通貨記号付き
$#,##0.00$1,250.00ドル
0.00%12.50%パーセント
yyyy-mm-dd2026-05-29ISO 形式の日付
[Red]-#,##0;0負数を赤条件付きカラー節

条件節や和暦書式を含む完全な文法は 表示形式のドキュメント を参照してください。

3. 真実の源はグリッド、Vue のリアクティビティはそれを購読する

ReoGrid Web は命令的な Canvas コンポーネントです。正しいパターンは「グリッドがデータを保持し、Vue が購読する」であり、「ref がデータを保持し、グリッドがそれを描画する」ではありません。考え方は React 版と同じですが、購読する側は Vue だと特にすっきり書けます。

グリッドのインスタンスを取得するには、コンポーネントにテンプレート ref を付け、そこから .instance を読みます。あとは @cell-value-change をフックして、再計算された合計値をリアクティブな ref にミラーすれば、UI 側でそのまま描画できます。

<script setup lang="ts">
import { ref } from 'vue';
import { Reogrid, type ReogridInstance } from '@reogrid/pro/vue';

const gridRef = ref<{ instance: ReogridInstance | null }>();
const total = ref(0);

function onReady({ worksheet: ws }: ReogridInstance) { /* 上記と同じセットアップ */ }

function onCellValueChange() {
  const ws = gridRef.value?.instance?.worksheet;
  if (!ws) return;
  // 編集後に再計算された合計セルを読む(ここでは E15 = 8 + items.length + 2)
  total.value = Number(ws.cell(`E${8 + items.length + 2}`).value) || 0;
}
</script>

<template>
  <Reogrid
    ref="gridRef"
    style="width: 100%; height: 700px"
    @ready="onReady"
    @cell-value-change="onCellValueChange"
  />
  <p>合計: <strong>¥{{ total.toLocaleString() }}</strong></p>
</template>

各セルを v-model でバインドしようとしないでください — それでは Canvas レンダラーが存在する意味がなくなりますし、グリッドは <canvas> に描画していて DOM には何も出さないので、そもそも Vue が差分を取る対象がありません。セルデータの真実の源はグリッドであり、refcomputed はグリッドから 流れ出てくる 派生/集計値だけを保持するのが正しい設計です。

このパターンの詳細は Sync Grid with React State レシピ を参照してください。原則はフレームワーク非依存で、useStateref / computed に置き換えるだけです。


“xlsx ダウンロード” ボタンを追加する

Pro 専用ですが、saveAsXlsx() を呼ぶだけ。ファイル生成とブラウザのダウンロードトリガを 1 回の呼び出しで完結します。ワークシートには同じ gridRef 経由でアクセスします。

<script setup lang="ts">
function downloadXlsx() {
  gridRef.value?.instance?.worksheet.saveAsXlsx({ filename: 'invoice.xlsx' });
}
</script>

<template>
  <button @click="downloadXlsx">xlsx でダウンロード</button>
</template>

オプションはどちらも任意です。filename のデフォルトは reogrid.xlsxsheetName のデフォルトは Sheet1。生バイト列が必要な場合(たとえばダウンロードではなくアップロードしたい場合)は、パッケージのルートから再エクスポートされた低レベル API の buildXlsxFromSnapshot() を使います。

エクスポートされたファイルは、結合済みヘッダー、列幅、すべてのスタイル、すべての数式(Excel 側で再評価されます — 凍結値ではありません)、表示形式、罫線をすべて保持します。Excel で開けば、グリッドにあったものはすべてそこにあります。


次のステップ

  • ロゴ画像を追加する: worksheet.images.add({ url: '/logo.png', row: 0, column: 0 })
  • 条件付き書式: 期限超過の明細行を赤で強調する。条件付き書式 を参照。
  • JSON で保存/復元: writeReoGridJson / readReoGridJson を使えば、xlsx を経由せずバックエンドとのロスレスなラウンドトリップが可能です。
  • React 版: まったく同じ onReady ロジックは @reogrid/pro/react<Reogrid> でも動きます。@readyonReady に戻すだけです。編集可能な数式付き React 製インボイス を参照。

無料の Lite ティア向けには、SUM の代わりに算術ベースで行小計を実装する Build an Invoice レシピ も用意しています。

ReoGrid Web を試してみる

React/Vue 向けの Canvas ベース Excel 互換スプレッドシートコンポーネント。 Lite は無料 — npm install 一発で始められます。

関連記事

ニュースレター

開発の最新情報をお届けします

新しいリリース・機能追加・お知らせをいち早く受け取るには、
メーリングリストにご登録ください。