💬

【React/TSX】Codexへの指示出しで帳票(消耗品在庫・購買実績報告書)を自動生成させてみたプロセス

に公開

業務システム開発において、複雑なレイアウトの帳票作成は非常に工数がかかるタスクの一つです。
今回は、Codex を活用して複雑なレイアウトの帳票を、React (TSX) コンポーネントとして自動生成させるプロセスをまとめました。

1回のプロンプトで完璧なものを作るのではなく、ベースを作成してから詳細なレイアウト修正や文言の置換指示を重ねて完成形に近づけるアプローチをとっています。

1. 初回の指示出し:ベースの作成

まずは、手元にある見本の画像ファイルと、基本的なルールセットを元に、ベースとなる templates/questionnaire.tsx を生成するよう指示を出しました。

見本となる帳票(Input)

指示内容(プロンプト)

最初のプロンプトでは、別途定義済みのルール(PROMPT_Codex_General.md)に従いつつ、見本を再現し、レイアウト崩れ(Overlap)がないかチェックすることを求めました。

docs/PROMPT_Codex_General.md のルールに従って、添付の見本から templates/questionnaire.tsx を生成してください。
Top Band 非重なり条件と二段ヘッダの overlay アルゴリズムを厳守し、ズレが残る場合は LAYOUT(title.h / SAFE_GAP / SUB_H / sub.y / pad.bottom)を自動調整して PASS 版を出力してください。
説明は不要、TSX の後に ---QA--- と厳密な JSON(score/issues/fixHints)を続けてください。

初回の出力結果

【この段階での課題】

  • レイアウトの衝突 (Overlap): 座標計算が甘く、テーブルのヘッダー部分が上部の「担当者名」や「電話番号」のエリアに大きく被ってしまっている。
  • 描画領域のあふれ: フォントサイズが大きく行間も広いため、A4用紙の所定の領域内にテーブルが収まりきっておらず、全体バランスが崩れている。
  • 構造の解釈ミス: 見本は複雑な「6カラム構成」だが、AIは簡易的に「4カラム」と解釈してしまっている。
  • スタイル: 太字(Bold)が多用されており、帳票特有の「細かく整然とした」雰囲気が再現できていない。

2. 指示の具体化と修正(Iterative Refinement)

初回の出力は全体像こそ捉えていましたが、帳票としてそのまま使えるレベルではありませんでした。ここから、Codexに対して細かい指示を重ねていきます。

具体的には、以下の4つの観点で修正を行いました。

① 座標干渉とオーバーフローの解消
初回出力では、座標計算の甘さとフォントサイズの大きさにより、以下の問題が起きていました。

  • レイアウトの衝突 (Overlap): テーブルのヘッダー部分が、上部の「担当者名」や「電話番号」の表示エリアに大きく被っている。
  • 描画領域のあふれ: フォントサイズが大きく行間も広いため、A4用紙の所定の領域内にテーブルが収まりきらず、ページ下部からはみ出している。

これらを解決するために、数値ベースで厳密な再配置を指示しました。

指示内容

  • 垂直位置の調整: タイトル群のY座標(titleY)を上に逃しつつ、テーブル開始位置(tableY)を下げて、ヘッダー情報と表の間に適切な余白(Safe Gap)を確保。
  • 情報密度の圧縮: 全体のフォントサイズを 7pt へ強制縮小し、行ごとのパディングを { top: 2, bottom: 2 } まで詰めることで、A4 1枚に全ての行が収まるよう全体高さを圧縮。

① グリッドシステムの完全な再定義(4カラム→6カラム)

初回の出力ではAIが表の構造を単純化して「4カラム」と解釈していましたが、実際には「時間」や「金額」の列が複雑に入り組んでいます。そこで、カラム定義を強制的に上書きしました。

指示内容:

  • カラム構成を6カラムに変更し、幅比率を 3, 25, 6, 4, 7, 55 に固定。

  • ヘッダ行を2段構成にし、行ごとに colspan(セル結合)ルールを明示。

    • 1行目: 「具体的な内容(2)」「空白(2)」「金額(1)」「備考(1)」
    • 2行目: 「【棚卸資産】備品Aの残数・評価(2)」「超過勤務時間(2)」「4,573(1)」「空白(1)」

狙い:
AIの推論に任せず、 widths: [16, 131, 31, 21, 37, 287] のように数値配列を与えることで、ピクセル単位でのレイアウト再現を狙いました。

② スタイルの「帳票化」(フォント・余白の圧縮)

初回の出力はWebブラウザで見やすい「モダンな余白」でしたが、紙の帳票は情報密度が非常に高いです。これを再現するためにスタイルを厳格化しました。

指示内容:

  • フォントサイズ: デフォルト(9〜11pt想定)から 7pt へ強制縮小。
  • ウェイト: bold(太字)を全廃。すべてのセルを normal ウェイトに統一。
  • パディング
    • ヘッダ: { top: 3, bottom: 3 }(少し広め)
    • 明細行: { top: 2, bottom: 2, left: 1, right: 1 }(極限まで詰める)
    • 背景色: ヘッダおよび集計行(合計欄など)に #F2F2F2 を適用し、視認性を向上。

③ 特殊レイアウトの実装(縦書き「内訳」と罫線制御)

標準的なHTMLテーブルでは表現しにくい、「左端列の結合」と「縦書き」の表現を追加しました。

指示内容:特定行の左端セルに「内」「訳」と改行を含めたテキストを配置。

明細行の左端(border-left)を消去するフラグ(borderlessLeft)を追加し、あたかも大項目が結合されているように見せる視覚的トリックを実装。

3. 完成した帳票

複数回の指示を経て、再出力された結果がこちらです。

ソースコード
import React from 'react'
import {
  css_page,
  css_table,
  css_td,
  css_text_font,
  asPdfStyle,
  absRightTopWithMargin
} from './_kit/css'

const pageH = 842
const M = { left: 36, right: 36, top: 36, bottom: 36 }

const TITLE_H = 22
const SAFE_GAP = 8
const SUB_H = 14
const SUB_PAD_BOTTOM = 4

const titleY = -52
const subLine1Y = -12
const subLine2Y = 6
const tableY = 44

const LAYOUT = {
  page: {
    pagesize: 'A4' as const,
    orientation: 'portrait' as const,
    font: 'HeiseiKakuGo-W5' as const,
    fontsize: 11,
    fontcolor: '#000000'
  },
  texts: {
    title: { x: 0, y: titleY, w: 523, h: TITLE_H, fontsize: 17 },
    issue: { x: 380, y: -38, w: 110, h: SUB_H, fontsize: 11 },
    pageNo: { x: 500, y: -38, w: 60, h: SUB_H, fontsize: 11, align: 'right' as const },
    municipality: { x: 0, y: subLine1Y, w: 360, h: SUB_H, fontsize: 11 },
    contact: { x: 0, y: subLine2Y, w: 520, h: SUB_H, fontsize: 11 }
  },
  table: {
    x: 0,
    y: tableY,
    width: 523,
    widths: [16, 131, 31, 21, 37, 287] as const,
    borderWidth: 0.8,
    borderColor: '#000000',
    fontsize: 7,
    cellpad: 4
  }
}

type Cell = {
  text: string
  colspan?: number
  align?: 'left' | 'center' | 'right'
  nowrap?: boolean
  sides?: Partial<{ top: boolean; left: boolean; right: boolean; bottom: boolean }>
  pad?: { top?: number; bottom?: number; left?: number; right?: number }
  leading?: number
  bgcolor?: string
}

type ClassificationRecord = {
  job_description_name: string
  expenditure: number
  overtime_hours?: number
  remarks?: string
}

type QuestionnaireRow = {
  item_name: string
  expenditure: number
  classification_list?: { record: ClassificationRecord }[]
  remarks?: string
}

type QuestionnaireData = {
  disaster_name: string
  local_government_name: string
  occupation_name: string
  user_name: string
  phone: string
  rows: QuestionnaireRow[]
  total_expenditure: number
}

const FULL_SPACE = ' '

const questionnaireData: QuestionnaireData = {
  disaster_name: '消耗品在庫・購買実績報告書',
  local_government_name: '東京本社 購買部',
  occupation_name: '',
  user_name: '山田 太郎',
  phone: '123-456-7890',
  rows: [
    {
      item_name: '【棚卸資産】備品Aの残数・評価',
      expenditure: 4573,
      classification_list: [
        {
          record: {
            job_description_name: '備品B在庫回転率',
            overtime_hours: 6.5,
            expenditure: 1234,
            remarks: '備品Bに関する在庫回転率の指標と、それを算出するための件数データです。'
          }
        },
        {
          record: {
            job_description_name: '備品C購買コスト指数',
            overtime_hours: 7.92,
            expenditure: 780,
            remarks: '備品Cに関する購買コスト指数の指標と、それを算出するための評価データです。'
          }
        },
        {
          record: {
            job_description_name: '備品D納品遅延件数',
            overtime_hours: 3.33,
            expenditure: 840,
            remarks: '備品Dに関する納品遅延の発生状況と件数のデータです。'
          }
        },
        {
          record: {
            job_description_name: '緊急発注率',
            overtime_hours: 9.33,
            expenditure: 888,
            remarks: '通常発注に対する緊急発注の比率と、関連する総発注件数のデータです。'
          }
        },
        {
          record: {
            job_description_name: '不良品率',
            overtime_hours: 8.08,
            expenditure: 831,
            remarks: '納品された全品目に対する不良品の発生率とその件数のデータです。'
          }
        }
      ]
    },
    {
      item_name: '【発注実績】合計金額(千円)',
      expenditure: 7052,
      classification_list: [
        {
          record: {
            job_description_name: '備品Aの年間購入額',
            expenditure: 3456,
            remarks: '備品Aに費やされた年間(または期間)の総購入金額です。'
          }
        },
        {
          record: {
            job_description_name: '備品Bの年間購入額',
            expenditure: 2345,
            remarks: '備品Bに費やされた年間(または期間)の総購入金額です。'
          }
        },
        {
          record: {
            job_description_name: '備品Cの返品件数',
            expenditure: 234,
            remarks: '備品Cにおける不良や誤発注による返品処理件数です。'
          }
        },
        {
          record: {
            job_description_name: '在庫日数の平均',
            expenditure: 450,
            remarks: '全品目における在庫が消費されるまでの平均日数です。'
          }
        },
        {
          record: {
            job_description_name: '購買担当者数',
            expenditure: 567,
            remarks: '購買業務に携わっている担当者の人数、または担当件数に関するデータです。'
          }
        }
      ]
    },
    {
      item_name: '【棚卸差異】報告',
      expenditure: 1467,
      classification_list: [
        {
          record: {
            job_description_name: '棚卸差異件数',
            expenditure: 678,
            remarks: '実地棚卸と帳簿棚卸の件数に差異が発生した件数です。'
          }
        },
        {
          record: {
            job_description_name: '発注伝票との不一致',
            expenditure: 789,
            remarks: '受領時の発注伝票と現物の数に不一致があった件数です。'
          }
        }
      ]
    },
    {
      item_name: '【消費実績】',
      expenditure: 944,
      classification_list: [
        {
          record: {
            job_description_name: '備品Eの日次平均消費数',
            expenditure: 462,
            remarks: '備品Eの1日あたりの平均的な消費数量です。'
          }
        },
        {
          record: {
            job_description_name: '発注書発行件数',
            expenditure: 482,
            remarks: '期間中にシステムから正式に発行された発注書の総件数です。'
          }
        }
      ]
    },
    {
      item_name: '【配送・納品】実績',
      expenditure: 1135,
      classification_list: [
        {
          record: {
            job_description_name: '配送業者別実績(件数)',
            expenditure: 405,
            remarks: '各配送業者ごとの納品実績件数です。'
          }
        },
        {
          record: {
            job_description_name: '発注キャンセル件数',
            expenditure: 730,
            remarks: '発注後に何らかの理由でキャンセルされた総件数です。'
          }
        }
      ]
    },
    {
      item_name: '【年間総発注額】',
      expenditure: 28493,
      remarks: '当該期間における、すべての発注の合計金額です。'
    },
    {
      item_name: '【購買管理】未処理件数',
      expenditure: 4957,
      remarks:
        '承認待ちやデータ入力待ちなど、購買プロセス内で滞留している未処理伝票の合計件数です。'
    },
    {
      item_name: '【納品管理】未納品件数',
      expenditure: 3456,
      remarks: '発注済みであるにもかかわらず、まだ納品が完了していない品目や伝票の合計件数です。'
    }
  ],
  total_expenditure: 52077
}

const formatNumber = (value?: number) =>
  typeof value === 'number' ? value.toLocaleString('ja-JP') : FULL_SPACE
const formatHours = (value?: number) => (typeof value === 'number' ? `${value}` : FULL_SPACE)

const cellStyle = (c: Cell) =>
  css_td({
    colspan: c.colspan,
    align: c.align,
    nowrap: c.nowrap,
    pad: c.pad,
    sides: c.sides,
    leading: c.leading,
    bgcolor: c.bgcolor
  })

const buildTableRows = () => {
  const rows: Cell[][] = []
  const headerPad = { top: 4, bottom: 4, left: 3, right: 3 }
  const detailPad = { top: 8, bottom: 8, left: 1, right: 1 }
  const summaryBg = '#F2F2F2'

  rows.push([
    { text: '具体的な内容', colspan: 2, align: 'center', pad: headerPad },
    { text: FULL_SPACE, colspan: 2, pad: headerPad },
    { text: '金額', align: 'center', pad: headerPad },
    { text: '備考', align: 'center', pad: headerPad }
  ])

  questionnaireData.rows.forEach((row) => {
    rows.push([
      { text: row.item_name, colspan: 4, bgcolor: summaryBg, pad: headerPad },
      { text: formatNumber(row.expenditure), align: 'right', bgcolor: summaryBg, pad: headerPad },
      {
        text: row.remarks && row.remarks.length ? row.remarks : FULL_SPACE,
        bgcolor: summaryBg,
        pad: headerPad,
        leading: row.remarks ? 12 : undefined
      }
    ])

    if (row.classification_list?.length) {
      const requestThirdLineUchiwake =
        row.item_name === '【棚卸資産】備品Aの残数・評価' ||
        row.item_name === '【発注実績】合計金額(千円)'
      const targetUchiIndex = requestThirdLineUchiwake ? 2 : 0
      const resolvedUchiIndex = Math.min(targetUchiIndex, row.classification_list.length - 1)

      row.classification_list.forEach((classification, idx) => {
        const record = classification.record
        const isUchiRow = idx === resolvedUchiIndex
        const leftText = isUchiRow ? '\n\n\n内\n訳' : FULL_SPACE
        const leftCellBase: Cell = {
          text: leftText,
          pad: detailPad,
          sides: { top: false, bottom: false },
          ...(isUchiRow ? { align: 'center' as const } : {})
        }

        if (typeof record.overtime_hours === 'number') {
          rows.push([
            leftCellBase,
            { text: record.job_description_name, pad: detailPad },
            { text: formatHours(record.overtime_hours), align: 'right', pad: detailPad },
            { text: '時間', align: 'center', pad: detailPad },
            { text: formatNumber(record.expenditure), align: 'right', pad: detailPad },
            {
              text: record.remarks && record.remarks.length ? record.remarks : FULL_SPACE,
              leading: record.remarks ? 12 : undefined,
              pad: detailPad
            }
          ])
        } else {
          rows.push([
            leftCellBase,
            { text: record.job_description_name, colspan: 3, pad: detailPad },
            { text: formatNumber(record.expenditure), align: 'right', pad: detailPad },
            {
              text: record.remarks && record.remarks.length ? record.remarks : FULL_SPACE,
              leading: record.remarks ? 12 : undefined,
              pad: detailPad
            }
          ])
        }
      })
    }
  })

  rows.push([
    { text: '合計', colspan: 2, align: 'center', bgcolor: summaryBg, pad: headerPad },
    { text: FULL_SPACE, bgcolor: summaryBg, pad: headerPad },
    { text: FULL_SPACE, bgcolor: summaryBg, pad: headerPad },
    {
      text: formatNumber(questionnaireData.total_expenditure),
      align: 'right',
      bgcolor: summaryBg,
      pad: headerPad
    },
    { text: FULL_SPACE, bgcolor: summaryBg, pad: headerPad }
  ])

  return rows
}

export const Questionnaire: React.FC = () => {
  const tableRows = buildTableRows()

  return (
    <div
      className="_page"
      style={css_page({
        ...LAYOUT.page,
        ...M
      })}
    >
      <p
        style={asPdfStyle({
          ...css_text_font(LAYOUT.texts.title.fontsize, 0),
          ...absRightTopWithMargin(
            LAYOUT.texts.title.x,
            LAYOUT.texts.title.y,
            TITLE_H,
            pageH,
            M.left,
            M.top
          )
        })}
      >
        {questionnaireData.disaster_name}
      </p>
      <p
        style={asPdfStyle({
          ...css_text_font(LAYOUT.texts.issue.fontsize, 0),
          ...absRightTopWithMargin(
            LAYOUT.texts.issue.x,
            LAYOUT.texts.issue.y,
            SUB_H,
            pageH,
            M.left,
            M.top
          )
        })}
      >
        発行日:2025/11/28
      </p>
      <p
        style={asPdfStyle({
          ...css_text_font(LAYOUT.texts.pageNo.fontsize, 0),
          align: LAYOUT.texts.pageNo.align,
          ...absRightTopWithMargin(
            LAYOUT.texts.pageNo.x,
            LAYOUT.texts.pageNo.y,
            SUB_H,
            pageH,
            M.left,
            M.top
          )
        })}
      >
        1/1
      </p>
      <p
        style={asPdfStyle({
          ...css_text_font(LAYOUT.texts.municipality.fontsize, 0),
          ...absRightTopWithMargin(
            LAYOUT.texts.municipality.x,
            LAYOUT.texts.municipality.y,
            SUB_H,
            pageH,
            M.left,
            M.top
          )
        })}
      >
        {questionnaireData.local_government_name}
      </p>
      <p
        style={asPdfStyle({
          ...css_text_font(LAYOUT.texts.contact.fontsize, 0),
          ...absRightTopWithMargin(
            LAYOUT.texts.contact.x,
            LAYOUT.texts.contact.y,
            SUB_H,
            pageH,
            M.left,
            M.top
          )
        })}
      >
        担当者名:{questionnaireData.user_name} 電話番号:{questionnaireData.phone}
      </p>

      <table
        style={css_table({
          cols: 6,
          width: LAYOUT.table.width,
          widths: LAYOUT.table.widths,
          abs: { x: M.left + LAYOUT.table.x, yTop: M.top + LAYOUT.table.y, pageH },
          border: { width: LAYOUT.table.borderWidth, color: LAYOUT.table.borderColor },
          cellpad: { all: LAYOUT.table.cellpad },
          fontsize: LAYOUT.table.fontsize,
          align: 'left',
          valign: 'middle'
        })}
      >
        {tableRows.map((row, rowIndex) => (
          <tr key={`row-${rowIndex}`}>
            {row.map((cell, cellIndex) => (
              <td key={`cell-${rowIndex}-${cellIndex}`} style={cellStyle(cell)}>
                <span>
                  {cell.text.split('\n').map((t, ti) => (
                    <React.Fragment key={`text-${rowIndex}-${cellIndex}-${ti}`}>
                      {ti > 0 && <br />}
                      {t}
                    </React.Fragment>
                  ))}
                </span>
              </td>
            ))}
          </tr>
        ))}
      </table>
    </div>
  )
}

export const renderToStaticMarkup = async (component: any) => {
  const ReactDOMServer = (await import('react-dom/server')).default
  return ReactDOMServer.renderToStaticMarkup(component)
}

export const getHtmlTemplate = async (_fileName: string) => {
  const element = (
    <html>
      <head></head>
      <body>
        <Questionnaire />
      </body>
    </html>
  )
  return await renderToStaticMarkup(element)
}

カラム幅が 3:25:6:4:7:55 の比率で適切に設定され、複雑なヘッダ結合、左端の「内訳」の表示、そして長文の備考欄が枠内にきれいに収まりました。

まとめ

Codexを利用することで、TypeScriptによる帳票レイアウトのコーディングを大幅に効率化できました。特に以下の手順が有効でした。

初期生成: まずはざっくりとした構造を作らせる。

数値調整: カラム幅やフォントサイズは、あいまいな指示ではなく数値(widths, pt)で指定する。

文言管理: 複雑な法的文言は、プロンプト段階で明確に指示することで、データ構造としてきれいにコード内に落とし込む。

vte.cx

Discussion