AI活用と意思決定デザイン

Code.gsとindex.htmlを貼り付ける

3イベントグッズ販売POSアプリをつくる
意思決定AI活用ChatGPTGeminiClaude

この時間でやること

今回は、前回作った Apps Script に、完成コードを貼り付けます。

やることはこれだけです。

Code.gs にコードを貼る
↓
index.html にコードを貼る
↓
保存する

まだコードの中身を全部理解しなくて大丈夫です。

まずは、動くアプリの材料を入れることが目標です。


今日のゴール

この第3節が終わると、Apps Script の中に次の2つのファイルが入った状態になります。

ファイル入れるもの
Code.gsアプリの裏側の処理
index.htmlアプリの画面

この2つがそろうと、次の節で setupApp() を実行できるようになります。


1. Googleスプレッドシートを開く

まず、前回作ったスプレッドシートを開きます。

Googleスプレッドシートはこちらから開けます。

https://docs.google.com/spreadsheets/

前回作ったファイル名を探します。

イベントグッズ販売POS

見つけたら開きます。


2. Apps Scriptを開く

スプレッドシート上部のメニューから、次の順番でクリックします。

拡張機能
↓
Apps Script

Apps Script の画面が開きます。

左側に、次の2つがあるか確認してください。

Code.gs
index.html

この2つがあれば準備OKです。


3. Code.gs にコードを貼り付ける

左側のファイル一覧から、

Code.gs

をクリックします。

最初から何か短いコードが入っている場合があります。

たとえば、こういうものです。

function myFunction() {

}

これは消してOKです。

Code.gs の中を全部選択します。

Windows:Ctrl + A
Mac:Command + A

そのあと、削除します。

Backspace または Delete

空になったら、 Code.gs 用の完成コード を貼り付けます。

const PRODUCT_SHEET_NAME = '商品マスタ';
const ORDER_SUMMARY_SHEET_NAME = '注文サマリー';
const ORDER_DETAIL_SHEET_NAME = '販売明細';
const SETTINGS_SHEET_NAME = '設定';
const INVENTORY_ADJUSTMENT_SHEET_NAME = '在庫調整履歴';
const CANCEL_HISTORY_SHEET_NAME = '取消履歴';

/**
 * 役割: WebアプリにアクセスされたときにHTMLを返す
 * 入力: e - URLパラメータ
 * 出力: HtmlOutput
 */
function doGet(e) {
  const template = HtmlService.createTemplateFromFile('index');

  template.page =
    e && e.parameter && e.parameter.page
      ? String(e.parameter.page)
      : 'pos';

  return template
    .evaluate()
    .setTitle('イベントグッズ販売POS')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * 役割: 初期設定として必要なシートを作成する
 * 入力: なし
 * 出力: なし
 */
function setupApp() {
  setupProductSheet();
  setupOrderSummarySheet();
  setupOrderDetailSheet();
  setupInventoryAdjustmentSheet();
  setupCancelHistorySheet();
  setupSettingsSheet();
}

/**
 * 役割: 在庫数を手動調整し、調整履歴を保存する
 * 入力: adjustmentData - 在庫調整データ
 * 出力: object - 更新結果
 */
function adjustInventory(adjustmentData) {
  const lock = LockService.getScriptLock();

  try {
    lock.waitLock(10000);

    const productId = String(adjustmentData.productId || '').trim();
    const newStockText = String(adjustmentData.newStock || '').trim();
    const reason = String(adjustmentData.reason || '').trim();
    const staffName = String(adjustmentData.staffName || '').trim();

    if (!productId) {
      throw new Error('商品IDが指定されていません。');
    }

    if (!staffName) {
      throw new Error('担当者を入力してください。');
    }

    if (!reason) {
      throw new Error('在庫調整理由を入力してください。');
    }

    const newStock = Number(newStockText);

    if (Number.isNaN(newStock) || newStock < 0) {
      throw new Error('在庫数は0以上の数字で入力してください。');
    }

    const productSheet = getOrCreateSheet(PRODUCT_SHEET_NAME, setupProductSheet);
    const adjustmentSheet = getOrCreateSheet(
      INVENTORY_ADJUSTMENT_SHEET_NAME,
      setupInventoryAdjustmentSheet
    );

    const productMap = getProductRowMap(productSheet);
    const product = productMap[productId];

    if (!product) {
      throw new Error('対象の商品が見つかりません。');
    }

    const beforeStock = product.currentStock;
    const afterStock = Math.floor(newStock);
    const adjustmentQuantity = afterStock - beforeStock;

    productSheet.getRange(product.rowNumber, 7).setValue(afterStock);

    if (afterStock <= 0) {
      productSheet.getRange(product.rowNumber, 8).setValue('売切');
    } else if (product.status === '売切') {
      productSheet.getRange(product.rowNumber, 8).setValue('販売中');
    }

    adjustmentSheet.appendRow([
      new Date(),
      product.productId,
      product.name,
      beforeStock,
      afterStock,
      adjustmentQuantity,
      reason,
      staffName,
    ]);

    return {
      success: true,
      productId: product.productId,
      productName: product.name,
      beforeStock,
      afterStock,
      adjustmentQuantity,
      message: '在庫を更新しました。',
    };
  } catch (error) {
    throw new Error(error.message || '在庫調整中にエラーが発生しました。');
  } finally {
    lock.releaseLock();
  }
}

/**
 * 役割: 在庫管理画面用の商品一覧を取得する
 * 入力: なし
 * 出力: 商品在庫配列
 */
function getInventoryData() {
  return getProducts().map((product) => {
    const soldCount = Math.max(0, product.initialStock - product.currentStock);

    return {
      productId: product.productId,
      name: product.name,
      category: product.category,
      imageUrl: product.imageUrl,
      price: product.price,
      initialStock: product.initialStock,
      currentStock: product.currentStock,
      soldCount,
      status: product.status,
      isSoldOut: product.isSoldOut,
      isAvailable: product.isAvailable,
    };
  });
}

/**
 * 役割: 注文を取消し、販売明細・注文サマリーを取消状態にし、在庫を戻す
 * 入力: cancelData - 取消情報
 * 出力: object - 取消結果
 */
function cancelOrder(cancelData) {
  const lock = LockService.getScriptLock();

  try {
    lock.waitLock(10000);

    const orderNumber = String(cancelData.orderNumber || '').trim();
    const reason = String(cancelData.reason || '').trim();
    const staffName = String(cancelData.staffName || '').trim();

    if (!orderNumber) {
      throw new Error('注文番号が指定されていません。');
    }

    if (!reason) {
      throw new Error('取消理由を入力してください。');
    }

    if (!staffName) {
      throw new Error('取消担当者を入力してください。');
    }

    const productSheet = getOrCreateSheet(PRODUCT_SHEET_NAME, setupProductSheet);
    const summarySheet = getOrCreateSheet(ORDER_SUMMARY_SHEET_NAME, setupOrderSummarySheet);
    const detailSheet = getOrCreateSheet(ORDER_DETAIL_SHEET_NAME, setupOrderDetailSheet);
    const cancelSheet = getOrCreateSheet(CANCEL_HISTORY_SHEET_NAME, setupCancelHistorySheet);

    const summaryValues = summarySheet.getDataRange().getValues();
    const detailValues = detailSheet.getDataRange().getValues();
    const productMap = getProductRowMap(productSheet);

    let targetSummaryRowNumber = null;
    let cancelAmount = 0;
    let summaryStatus = '';

    for (let index = 1; index < summaryValues.length; index++) {
      const row = summaryValues[index];

      if (String(row[1] || '') === orderNumber) {
        targetSummaryRowNumber = index + 1;
        cancelAmount = Number(row[2] || 0);
        summaryStatus = String(row[8] || '');
        break;
      }
    }

    if (!targetSummaryRowNumber) {
      throw new Error('対象の注文が見つかりません。');
    }

    if (summaryStatus === '取消') {
      throw new Error('この注文はすでに取消済みです。');
    }

    const targetDetailRows = [];

    for (let index = 1; index < detailValues.length; index++) {
      const row = detailValues[index];

      if (String(row[1] || '') === orderNumber && String(row[8] || '') === '完了') {
        targetDetailRows.push({
          rowNumber: index + 1,
          productId: String(row[2] || ''),
          productName: String(row[3] || ''),
          quantity: Number(row[6] || 0),
        });
      }
    }

    if (targetDetailRows.length === 0) {
      throw new Error('取消対象の販売明細が見つかりません。');
    }

    targetDetailRows.forEach((detail) => {
      const product = productMap[detail.productId];

      if (!product) {
        throw new Error(`商品が見つかりません。商品ID: ${detail.productId}`);
      }

      const restoredStock = product.currentStock + detail.quantity;

      productSheet.getRange(product.rowNumber, 7).setValue(restoredStock);

      if (restoredStock > 0 && product.status === '売切') {
        productSheet.getRange(product.rowNumber, 8).setValue('販売中');
      }

      detailSheet.getRange(detail.rowNumber, 9).setValue('取消');
    });

    summarySheet.getRange(targetSummaryRowNumber, 9).setValue('取消');

    cancelSheet.appendRow([
      new Date(),
      orderNumber,
      cancelAmount,
      reason,
      staffName,
      '済',
    ]);

    return {
      success: true,
      orderNumber,
      cancelAmount,
      restoredItemCount: targetDetailRows.length,
      message: '注文を取消し、在庫を戻しました。',
    };
  } catch (error) {
    throw new Error(error.message || '注文取消中にエラーが発生しました。');
  } finally {
    lock.releaseLock();
  }
}

/**
 * 役割: 注文番号に紐づく販売明細を取得する
 * 入力: orderNumber - 注文番号
 * 出力: 販売明細配列
 */
function getOrderDetails(orderNumber) {
  const targetOrderNumber = String(orderNumber || '').trim();

  if (!targetOrderNumber) {
    throw new Error('注文番号が指定されていません。');
  }

  const detailSheet = getOrCreateSheet(ORDER_DETAIL_SHEET_NAME, setupOrderDetailSheet);
  const values = detailSheet.getDataRange().getValues();
  const rows = values.slice(1);

  return rows
    .filter((row) => String(row[1] || '') === targetOrderNumber)
    .map((row) => {
      return {
        orderedAt: formatDateTime(row[0]),
        orderNumber: String(row[1] || ''),
        productId: String(row[2] || ''),
        name: String(row[3] || ''),
        category: String(row[4] || ''),
        price: Number(row[5] || 0),
        quantity: Number(row[6] || 0),
        subtotal: Number(row[7] || 0),
        status: String(row[8] || ''),
      };
    });
}

/**
 * 役割: 販売履歴画面用に注文履歴を取得する
 * 入力: なし
 * 出力: 注文履歴配列
 */
function getOrderHistory() {
  const summarySheet = getOrCreateSheet(ORDER_SUMMARY_SHEET_NAME, setupOrderSummarySheet);
  const values = summarySheet.getDataRange().getValues();
  const rows = values.slice(1);

  return rows
    .slice()
    .reverse()
    .slice(0, 100)
    .map((row) => {
      return {
        orderedAt: formatDateTime(row[0]),
        orderNumber: String(row[1] || ''),
        orderTotal: Number(row[2] || 0),
        receivedAmount: Number(row[3] || 0),
        changeAmount: Number(row[4] || 0),
        paymentMethod: String(row[5] || ''),
        staffName: String(row[6] || ''),
        customerName: String(row[7] || ''),
        status: String(row[8] || ''),
      };
    });
}

/**
 * 役割: 取消履歴シートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupCancelHistorySheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(CANCEL_HISTORY_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(CANCEL_HISTORY_SHEET_NAME);
  }

  const headers = [
    '取消日時',
    '注文番号',
    '取消金額',
    '取消理由',
    '担当者',
    '在庫戻し',
  ];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  return sheet;
}

/**
 * 役割: 在庫調整履歴シートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupInventoryAdjustmentSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(INVENTORY_ADJUSTMENT_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(INVENTORY_ADJUSTMENT_SHEET_NAME);
  }

  const headers = [
    '調整日時',
    '商品ID',
    '商品名',
    '調整前在庫',
    '調整後在庫',
    '調整数',
    '理由',
    '担当者',
  ];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  return sheet;
}

/**
 * 役割: 商品マスタシートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupProductSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(PRODUCT_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(PRODUCT_SHEET_NAME);
  }

  const headers = [
    '商品ID',
    '商品名',
    'カテゴリ',
    '画像URL',
    '価格',
    '初期在庫',
    '現在庫',
    '販売状態',
    '表示順',
    '備考',
  ];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  if (sheet.getLastRow() > 1) {
    return sheet;
  }

  const sampleRows = [
    [
      'P001',
      '学校祭Tシャツ',
      'アパレル',
      'https://placehold.co/600x400?text=T-Shirt',
      2000,
      50,
      50,
      '販売中',
      1,
      '',
    ],
    [
      'P002',
      'オリジナルトートバッグ',
      'バッグ',
      'https://placehold.co/600x400?text=Bag',
      1500,
      40,
      40,
      '販売中',
      2,
      '',
    ],
    [
      'P003',
      'アクリルキーホルダー',
      '雑貨',
      'https://placehold.co/600x400?text=Keyholder',
      700,
      80,
      80,
      '販売中',
      3,
      '',
    ],
    [
      'P004',
      'ステッカーセット',
      '雑貨',
      'https://placehold.co/600x400?text=Sticker',
      500,
      100,
      100,
      '販売中',
      4,
      '',
    ],
  ];

  sheet.getRange(2, 1, sampleRows.length, headers.length).setValues(sampleRows);

  return sheet;
}

/**
 * 役割: 注文サマリーシートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupOrderSummarySheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(ORDER_SUMMARY_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(ORDER_SUMMARY_SHEET_NAME);
  }

  const headers = [
    '注文日時',
    '注文番号',
    '注文合計',
    '受取金額',
    'お釣り',
    '支払い方法',
    '販売担当者',
    '購入者名',
    'ステータス',
    'UserAgent',
  ];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  return sheet;
}

/**
 * 役割: 販売明細シートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupOrderDetailSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(ORDER_DETAIL_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(ORDER_DETAIL_SHEET_NAME);
  }

  const headers = [
    '注文日時',
    '注文番号',
    '商品ID',
    '商品名',
    'カテゴリ',
    '単価',
    '数量',
    '小計',
    'ステータス',
  ];

  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  return sheet;
}

/**
 * 役割: 設定シートを作成する
 * 入力: なし
 * 出力: Sheet
 */
function setupSettingsSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(SETTINGS_SHEET_NAME);

  if (!sheet) {
    sheet = spreadsheet.insertSheet(SETTINGS_SHEET_NAME);
  }

  const headers = ['項目', '値'];
  sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
  sheet.setFrozenRows(1);

  if (sheet.getLastRow() > 1) {
    return sheet;
  }

  const rows = [
    ['イベント名', '学校祭グッズ販売'],
    ['目標売上', '100000'],
    ['販売状態', '販売中'],
    ['支払い方法', '現金'],
  ];

  sheet.getRange(2, 1, rows.length, 2).setValues(rows);

  return sheet;
}

/**
 * 役割: 商品一覧を取得する
 * 入力: なし
 * 出力: 商品配列
 */
function getProducts() {
  const sheet = getOrCreateSheet(PRODUCT_SHEET_NAME, setupProductSheet);
  const values = sheet.getDataRange().getValues();
  const rows = values.slice(1);

  return rows
    .filter((row) => String(row[0] || '').trim())
    .map((row) => {
      const initialStock = Number(row[5] || 0);
      const currentStock = Number(row[6] || 0);
      const status = String(row[7] || '販売中');

      return {
        productId: String(row[0] || ''),
        name: String(row[1] || ''),
        category: String(row[2] || ''),
        imageUrl: String(row[3] || ''),
        price: Number(row[4] || 0),
        initialStock,
        currentStock,
        status,
        sortOrder: Number(row[8] || 999),
        memo: String(row[9] || ''),
        isSoldOut: currentStock <= 0 || status === '売切',
        isAvailable: status === '販売中' && currentStock > 0,
      };
    })
    .sort((a, b) => a.sortOrder - b.sortOrder);
}

/**
 * 役割: 注文を確定し、注文サマリー・販売明細・在庫を更新する
 * 入力: orderData - カート・会計情報
 * 出力: 注文完了情報
 */
function submitOrder(orderData) {
  const lock = LockService.getScriptLock();

  try {
    lock.waitLock(10000);

    const validatedOrder = validateOrderData(orderData);
    const productSheet = getOrCreateSheet(PRODUCT_SHEET_NAME, setupProductSheet);
    const summarySheet = getOrCreateSheet(ORDER_SUMMARY_SHEET_NAME, setupOrderSummarySheet);
    const detailSheet = getOrCreateSheet(ORDER_DETAIL_SHEET_NAME, setupOrderDetailSheet);

    const productMap = getProductRowMap(productSheet);
    const now = new Date();
    const orderNumber = createOrderNumber(summarySheet.getLastRow() + 1);

    let orderTotal = 0;
    const detailRows = [];
    const stockUpdates = [];

    validatedOrder.items.forEach((item) => {
      const productRecord = productMap[item.productId];

      if (!productRecord) {
        throw new Error(`商品が見つかりません。商品ID: ${item.productId}`);
      }

      if (productRecord.status !== '販売中') {
        throw new Error(`${productRecord.name} は現在販売中ではありません。`);
      }

      if (productRecord.currentStock < item.quantity) {
        throw new Error(`${productRecord.name} の在庫が不足しています。現在庫: ${productRecord.currentStock}`);
      }

      const subtotal = productRecord.price * item.quantity;
      orderTotal += subtotal;

      detailRows.push([
        now,
        orderNumber,
        productRecord.productId,
        productRecord.name,
        productRecord.category,
        productRecord.price,
        item.quantity,
        subtotal,
        '完了',
      ]);

      stockUpdates.push({
        rowNumber: productRecord.rowNumber,
        newStock: productRecord.currentStock - item.quantity,
      });
    });

    if (validatedOrder.receivedAmount < orderTotal) {
      throw new Error('受取金額が注文合計より少ないです。');
    }

    const changeAmount = validatedOrder.receivedAmount - orderTotal;

    summarySheet.appendRow([
      now,
      orderNumber,
      orderTotal,
      validatedOrder.receivedAmount,
      changeAmount,
      '現金',
      validatedOrder.staffName,
      validatedOrder.customerName,
      '完了',
      validatedOrder.userAgent,
    ]);

    if (detailRows.length > 0) {
      detailSheet
        .getRange(detailSheet.getLastRow() + 1, 1, detailRows.length, detailRows[0].length)
        .setValues(detailRows);
    }

    stockUpdates.forEach((update) => {
      productSheet.getRange(update.rowNumber, 7).setValue(update.newStock);
    });

    return {
      success: true,
      orderNumber,
      orderTotal,
      receivedAmount: validatedOrder.receivedAmount,
      changeAmount,
      staffName: validatedOrder.staffName,
      customerName: validatedOrder.customerName,
      items: detailRows.map((row) => {
        return {
          productId: row[2],
          name: row[3],
          category: row[4],
          price: row[5],
          quantity: row[6],
          subtotal: row[7],
        };
      }),
      message: '決済が完了しました。',
    };
  } catch (error) {
    throw new Error(error.message || '注文処理中にエラーが発生しました。');
  } finally {
    lock.releaseLock();
  }
}

/**
 * 役割: 注文データを検証する
 * 入力: orderData - 注文データ
 * 出力: 検証済み注文データ
 */
function validateOrderData(orderData) {
  if (!orderData) {
    throw new Error('注文データが送信されていません。');
  }

  const items = Array.isArray(orderData.items) ? orderData.items : [];
  const staffName = String(orderData.staffName || '').trim();
  const customerName = String(orderData.customerName || '').trim();
  const receivedAmount = Number(orderData.receivedAmount || 0);
  const userAgent = String(orderData.userAgent || '').trim();

  if (items.length === 0) {
    throw new Error('商品が選択されていません。');
  }

  if (!staffName) {
    throw new Error('販売担当者を入力してください。');
  }

  if (Number.isNaN(receivedAmount) || receivedAmount < 0) {
    throw new Error('受取金額を正しく入力してください。');
  }

  const validatedItems = items.map((item) => {
    const productId = String(item.productId || '').trim();
    const quantity = Number(item.quantity || 0);

    if (!productId) {
      throw new Error('商品IDが不正です。');
    }

    if (Number.isNaN(quantity) || quantity <= 0) {
      throw new Error('数量は1以上で指定してください。');
    }

    return {
      productId,
      quantity: Math.floor(quantity),
    };
  });

  return {
    items: validatedItems,
    staffName,
    customerName,
    receivedAmount,
    userAgent,
  };
}

/**
 * 役割: 商品IDをキーにして商品行情報を取得する
 * 入力: productSheet - 商品マスタシート
 * 出力: 商品行マップ
 */
function getProductRowMap(productSheet) {
  const values = productSheet.getDataRange().getValues();
  const rows = values.slice(1);
  const productMap = {};

  rows.forEach((row, index) => {
    const productId = String(row[0] || '').trim();

    if (!productId) {
      return;
    }

    productMap[productId] = {
      rowNumber: index + 2,
      productId,
      name: String(row[1] || ''),
      category: String(row[2] || ''),
      imageUrl: String(row[3] || ''),
      price: Number(row[4] || 0),
      initialStock: Number(row[5] || 0),
      currentStock: Number(row[6] || 0),
      status: String(row[7] || '販売中'),
      sortOrder: Number(row[8] || 999),
      memo: String(row[9] || ''),
    };
  });

  return productMap;
}

/**
 * 役割: 管理ダッシュボード用データを取得する
 * 入力: なし
 * 出力: 集計データ
 */
function getDashboardData() {
  const products = getProducts();
  const summarySheet = getOrCreateSheet(ORDER_SUMMARY_SHEET_NAME, setupOrderSummarySheet);
  const detailSheet = getOrCreateSheet(ORDER_DETAIL_SHEET_NAME, setupOrderDetailSheet);

  const summaryValues = summarySheet.getDataRange().getValues().slice(1);
  const detailValues = detailSheet.getDataRange().getValues().slice(1);

  const completedSummaries = summaryValues.filter((row) => String(row[8] || '') === '完了');
  const completedDetails = detailValues.filter((row) => String(row[8] || '') === '完了');

  const totalSales = completedSummaries.reduce((sum, row) => sum + Number(row[2] || 0), 0);
  const totalOrders = completedSummaries.length;
  const totalItems = completedDetails.reduce((sum, row) => sum + Number(row[6] || 0), 0);
  const averageOrderValue = totalOrders === 0 ? 0 : Math.round(totalSales / totalOrders);

  const productSalesCounts = {};
  const productSalesAmounts = {};
  const categorySalesAmounts = {};
  const staffSalesAmounts = {};
  const hourlySalesAmounts = {};

  for (let hour = 0; hour < 24; hour++) {
    const hourLabel = `${String(hour).padStart(2, '0')}:00`;
    hourlySalesAmounts[hourLabel] = 0;
  }

  completedDetails.forEach((row) => {
    const orderedAt = row[0];
    const productName = String(row[3] || '未設定');
    const category = String(row[4] || '未設定');
    const quantity = Number(row[6] || 0);
    const subtotal = Number(row[7] || 0);

    productSalesCounts[productName] = (productSalesCounts[productName] || 0) + quantity;
    productSalesAmounts[productName] = (productSalesAmounts[productName] || 0) + subtotal;
    categorySalesAmounts[category] = (categorySalesAmounts[category] || 0) + subtotal;

    if (orderedAt instanceof Date) {
      const hourLabel = Utilities.formatDate(
        orderedAt,
        Session.getScriptTimeZone(),
        'HH:00'
      );

      hourlySalesAmounts[hourLabel] = (hourlySalesAmounts[hourLabel] || 0) + subtotal;
    }
  });

  completedSummaries.forEach((row) => {
    const staffName = String(row[6] || '未入力');
    const orderTotal = Number(row[2] || 0);
    staffSalesAmounts[staffName] = (staffSalesAmounts[staffName] || 0) + orderTotal;
  });

  const soldOutProducts = products.filter((product) => product.currentStock <= 0 || product.status === '売切');

  const latestOrders = completedSummaries
    .slice()
    .reverse()
    .slice(0, 10)
    .map((row) => {
      return {
        orderedAt: formatDateTime(row[0]),
        orderNumber: String(row[1] || ''),
        orderTotal: Number(row[2] || 0),
        receivedAmount: Number(row[3] || 0),
        changeAmount: Number(row[4] || 0),
        paymentMethod: String(row[5] || ''),
        staffName: String(row[6] || ''),
        customerName: String(row[7] || ''),
        status: String(row[8] || ''),
      };
    });

  return {
    totalSales,
    totalOrders,
    totalItems,
    averageOrderValue,
    productSalesCounts,
    productSalesAmounts,
    categorySalesAmounts,
    staffSalesAmounts,
    hourlySalesAmounts,
    soldOutCount: soldOutProducts.length,
    soldOutProducts,
    products,
    latestOrders,
    updatedAt: formatDateTime(new Date()),
  };
}

/**
 * 役割: 注文番号を生成する
 * 入力: rowNumber - 注文サマリー上の次行番号
 * 出力: 注文番号
 */
function createOrderNumber(rowNumber) {
  const today = Utilities.formatDate(
    new Date(),
    Session.getScriptTimeZone(),
    'yyyyMMdd'
  );

  const paddedNumber = String(rowNumber - 1).padStart(4, '0');

  return `ORD-${today}-${paddedNumber}`;
}

/**
 * 役割: シートを取得し、なければ作成する
 * 入力: sheetName - シート名, setupFunction - 作成関数
 * 出力: Sheet
 */
function getOrCreateSheet(sheetName, setupFunction) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(sheetName);

  if (!sheet) {
    sheet = setupFunction();
  }

  return sheet;
}

/**
 * 役割: 金額表示用の文字列に変換する
 * 入力: value - 数値
 * 出力: string
 */
function formatCurrency(value) {
  return ${Number(value || 0).toLocaleString('ja-JP')}`;
}

/**
 * 役割: 日時を画面表示用に整形する
 * 入力: value - Date
 * 出力: string
 */
function formatDateTime(value) {
  if (!(value instanceof Date)) {
    return '';
  }

  return Utilities.formatDate(
    value,
    Session.getScriptTimeZone(),
    'yyyy/MM/dd HH:mm:ss'
  );
}

/**
 * 役割: 注文処理のテストを行う
 * 入力: なし
 * 出力: なし
 */
function testSubmitOrder() {
  const result = submitOrder({
    items: [
      {
        productId: 'P001',
        quantity: 1,
      },
      {
        productId: 'P003',
        quantity: 2,
      },
    ],
    staffName: 'テスト担当',
    customerName: 'テスト購入者',
    receivedAmount: 5000,
    userAgent: 'Apps Script Test',
  });

  Logger.log(JSON.stringify(result, null, 2));
}

4. index.html にコードを貼り付ける

次に、左側のファイル一覧から、

index.html

をクリックします。

ここにも、最初から少しだけHTMLが入っていることがあります。

それも全部消してOKです。

同じように全部選択します。

Windows:Ctrl + A
Mac:Command + A

削除します。

そのあと、index.html 用の完成コード を貼り付けます。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <base target="_top" />
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>イベントグッズ販売POS</title>

    <style>
  /* =========================================================
     Zenn Design System
     - Primary: #3ea8ff
     - Text: rgba(0,0,0,0.82)
     - Background: #ffffff
     - Surface: #f1f5f9
     - Border: #d6e3ed
     - CSS Custom Properties は使わず直接指定
     ========================================================= */

  *,
  *::before,
  *::after {
    box-sizing: border-box;
  }

  html,
  body {
    margin: 0;
    min-height: 100%;
    background: #ffffff;
    color: rgba(0, 0, 0, 0.82);
    font-family:
      -apple-system,
      "system-ui",
      "Hiragino Kaku Gothic ProN",
      "Hiragino Sans",
      Meiryo,
      sans-serif;
    font-size: 16px;
    line-height: 1.8;
    letter-spacing: normal;
    word-break: break-all;
    overflow-wrap: break-word;
    font-feature-settings: normal;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
  }

  button,
  input {
    font: inherit;
    color: inherit;
    letter-spacing: normal;
  }

  ::selection {
    background: rgba(62, 168, 255, 0.18);
    color: rgba(0, 0, 0, 0.82);
  }

  /* =========================================================
     App Shell
     ========================================================= */

  .app {
    display: grid;
    grid-template-columns: 1fr;
    min-height: 100vh;
    background: #ffffff;
  }

  .sidebar {
    position: sticky;
    top: 0;
    z-index: 20;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    padding: 12px 20px;
    background: rgba(255, 255, 255, 0.96);
    border-bottom: 1px solid #d6e3ed;
  }

  .sidebar-header {
    display: flex;
    align-items: center;
    gap: 12px;
    min-width: 0;
  }

  .brand-mark {
    width: 36px;
    height: 36px;
    border-radius: 10px;
    background: #3ea8ff;
    display: grid;
    place-items: center;
    color: #ffffff;
    font-weight: 700;
    font-size: 14px;
    flex-shrink: 0;
  }

  .brand-text {
    display: flex;
    flex-direction: column;
    line-height: 1.5;
    min-width: 0;
  }

  .brand-kicker {
    font-size: 12px;
    font-weight: 400;
    color: rgba(0, 0, 0, 0.55);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .brand-title {
    font-size: 16px;
    font-weight: 700;
    color: rgba(0, 0, 0, 0.82);
  }

  .nav {
    display: flex;
    flex-direction: row;
    gap: 8px;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }

  .nav::-webkit-scrollbar {
    display: none;
  }

  .nav-button {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 8px 16px;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    color: rgba(0, 0, 0, 0.55);
    font-size: 14px;
    font-weight: 600;
    line-height: 1.5;
    white-space: nowrap;
    cursor: pointer;
    transition:
      background-color 0.16s ease,
      border-color 0.16s ease,
      color 0.16s ease,
      box-shadow 0.16s ease;
  }

  .nav-button:hover {
    background: #f1f5f9;
    color: rgba(0, 0, 0, 0.82);
  }

  .nav-button.is-active {
    background: #3ea8ff;
    border-color: #3ea8ff;
    color: #ffffff;
    font-weight: 700;
  }

  .nav-icon {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
  }

  .main {
    min-width: 0;
    padding: 32px 20px 48px;
  }

  .main-inner {
    width: 100%;
    max-width: 1180px;
    margin: 0 auto;
  }

  .page-header {
    margin-bottom: 32px;
  }

  .page-kicker {
    margin: 0 0 8px;
    color: #3ea8ff;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.5;
  }

  .page-title {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: clamp(28px, 4.2vw, 38.4px);
    font-weight: 700;
    line-height: 1.5;
    letter-spacing: normal;
  }

  .page-lead {
    margin: 12px 0 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 16px;
    font-weight: 400;
    line-height: 1.8;
    max-width: 820px;
  }

  .view {
    display: none;
  }

  .view.is-active {
    display: block;
    animation: viewFadeIn 0.24s ease;
  }

  @keyframes viewFadeIn {
    from {
      opacity: 0;
      transform: translateY(4px);
    }

    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  /* =========================================================
     Layout
     ========================================================= */

  .layout {
    display: grid;
    grid-template-columns: 1fr;
    gap: 24px;
  }

  .product-grid,
  .inventory-grid,
  .history-grid,
  .dashboard-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 16px;
  }

  /* =========================================================
     Cards / Panels
     ========================================================= */

  .product-card,
  .panel,
  .dashboard-card,
  .inventory-card,
  .history-card,
  .success-box {
    background: #ffffff;
    border: 1px solid #d6e3ed;
    border-radius: 12px;
    box-shadow: none;
    padding: 20px;
    transition:
      box-shadow 0.18s ease,
      transform 0.18s ease,
      border-color 0.18s ease;
  }

  .product-card:hover,
  .inventory-card:hover,
  .history-card:hover,
  .dashboard-card:hover {
    border-color: rgba(62, 168, 255, 0.45);
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
  }

  .panel {
    padding: 24px;
    background: #ffffff;
  }

  /* =========================================================
     Product Card
     ========================================================= */

  .product-image {
    width: 100%;
    aspect-ratio: 4 / 3;
    object-fit: cover;
    border-radius: 12px;
    background: #f1f5f9;
    border: 1px solid #d6e3ed;
    margin-bottom: 16px;
  }

  .product-meta,
  .inventory-card-header,
  .history-card-header {
    display: flex;
    justify-content: space-between;
    gap: 16px;
    align-items: flex-start;
  }

  .product-name,
  .inventory-title,
  .history-title {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16.8px;
    font-weight: 700;
    line-height: 1.5;
  }

  .product-category,
  .inventory-sub,
  .history-sub {
    margin: 4px 0 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
  }

  .product-price {
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
  }

  .stock-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    margin-top: 16px;
    color: rgba(0, 0, 0, 0.55);
    font-size: 14px;
    font-weight: 400;
    line-height: 1.5;
  }

  .stock-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: 28px;
    padding: 4px 10px;
    border-radius: 999px;
    background: rgba(16, 185, 129, 0.12);
    color: #10b981;
    font-size: 12px;
    font-weight: 600;
    line-height: 1.5;
    white-space: nowrap;
  }

  .stock-badge.is-soldout {
    background: rgba(244, 63, 94, 0.12);
    color: #f43f5e;
  }

  .stock-badge.is-warning {
    background: rgba(245, 158, 11, 0.14);
    color: #f59e0b;
  }

  /* =========================================================
     Buttons
     ========================================================= */

  .button,
  .add-button,
  .primary-button,
  .secondary-button,
  .small-button {
    min-height: 44px;
    border-radius: 8px;
    font-weight: 700;
    cursor: pointer;
    transition:
      background-color 0.16s ease,
      border-color 0.16s ease,
      color 0.16s ease,
      box-shadow 0.16s ease,
      transform 0.12s ease;
  }

  .add-button,
  .primary-button,
  .secondary-button {
    width: 100%;
    padding: 8px 24px;
    font-size: 16px;
    line-height: 1.5;
  }

  .add-button,
  .primary-button {
    margin-top: 16px;
    border: 1px solid #3ea8ff;
    background: #3ea8ff;
    color: #ffffff;
  }

  .add-button:hover:not(:disabled),
  .primary-button:hover:not(:disabled) {
    background: #0f83fd;
    border-color: #0f83fd;
  }

  .add-button:active:not(:disabled),
  .primary-button:active:not(:disabled) {
    transform: translateY(1px);
  }

  .secondary-button {
    margin-top: 12px;
    border: 1px solid #3ea8ff;
    background: transparent;
    color: #3ea8ff;
  }

  .secondary-button:hover:not(:disabled) {
    background: rgba(62, 168, 255, 0.08);
  }

  .small-button {
    min-height: 40px;
    padding: 8px 16px;
    border: 1px solid #3ea8ff;
    background: #3ea8ff;
    color: #ffffff;
    font-size: 14px;
    line-height: 1.5;
  }

  .small-button:hover:not(:disabled) {
    background: #0f83fd;
    border-color: #0f83fd;
  }

  .small-button.secondary {
    background: transparent;
    border-color: #3ea8ff;
    color: #3ea8ff;
  }

  .small-button.secondary:hover:not(:disabled) {
    background: rgba(62, 168, 255, 0.08);
  }

  .small-button.danger {
    background: #f43f5e;
    border-color: #f43f5e;
    color: #ffffff;
  }

  .small-button.danger:hover:not(:disabled) {
    background: #e11d48;
    border-color: #e11d48;
  }

  button:disabled {
    opacity: 0.42;
    cursor: not-allowed;
  }

  button:focus-visible {
    outline: 3px solid rgba(62, 168, 255, 0.32);
    outline-offset: 2px;
  }

  /* =========================================================
     Cart
     ========================================================= */

  .cart-title {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
  }

  .cart-list {
    display: grid;
    gap: 0;
    margin-top: 16px;
  }

  .cart-row {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 12px;
    align-items: center;
    padding: 16px 0;
    border-bottom: 1px solid #d6e3ed;
  }

  .cart-row:last-child {
    border-bottom: none;
  }

  .cart-name {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
  }

  .cart-sub {
    margin: 4px 0 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  .qty-control {
    display: flex;
    align-items: center;
    gap: 8px;
  }

  .qty-button {
    width: 36px;
    height: 36px;
    border: 1px solid #d6e3ed;
    border-radius: 8px;
    background: #ffffff;
    color: #3ea8ff;
    font-size: 18px;
    font-weight: 700;
    line-height: 1;
    cursor: pointer;
    transition:
      background-color 0.16s ease,
      border-color 0.16s ease;
  }

  .qty-button:hover {
    background: #f1f5f9;
    border-color: #3ea8ff;
  }

  .qty-value {
    min-width: 24px;
    text-align: center;
    font-weight: 700;
    font-size: 16px;
    font-variant-numeric: tabular-nums;
  }

  /* =========================================================
     Total Box
     ========================================================= */

  .total-box {
    margin-top: 20px;
    padding: 20px;
    border-radius: 12px;
    background: #f1f5f9;
    border: 1px solid #d6e3ed;
  }

  .total-label {
    margin: 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
  }

  .total-value {
    margin: 8px 0 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 38.4px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  /* =========================================================
     Form
     ========================================================= */

  .field,
  .inline-field {
    display: grid;
    gap: 8px;
    margin-top: 16px;
  }

  label {
    color: rgba(0, 0, 0, 0.55);
    font-size: 14px;
    font-weight: 600;
    line-height: 1.5;
  }

  input {
    width: 100%;
    height: 40px;
    border: 1px solid #d6e3ed;
    border-radius: 8px;
    background: #ffffff;
    padding: 8px 12px;
    outline: none;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    transition:
      border-color 0.16s ease,
      box-shadow 0.16s ease;
  }

  input::placeholder {
    color: rgba(0, 0, 0, 0.36);
  }

  input:focus {
    border-color: #3ea8ff;
    box-shadow: 0 0 0 3px rgba(62, 168, 255, 0.16);
  }

  input[type="number"] {
    font-variant-numeric: tabular-nums;
  }

  .change-box {
    margin-top: 16px;
    padding: 16px;
    border-radius: 12px;
    background: rgba(16, 185, 129, 0.1);
    border: 1px solid rgba(16, 185, 129, 0.24);
    color: #10b981;
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  .error {
    display: none;
    margin-top: 16px;
    padding: 12px 16px;
    border-radius: 8px;
    background: rgba(244, 63, 94, 0.1);
    border: 1px solid rgba(244, 63, 94, 0.24);
    color: #f43f5e;
    font-size: 14px;
    font-weight: 600;
    line-height: 1.5;
  }

  .error.is-visible {
    display: block;
  }

  /* =========================================================
     Success
     ========================================================= */

  .success-box {
    padding: 40px;
    max-width: 560px;
    margin: 0 auto;
  }

  .success-title {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 38.4px;
    font-weight: 700;
    line-height: 1.5;
  }

  .success-description {
    margin: 12px 0 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 16px;
    font-weight: 400;
    line-height: 1.8;
  }

  .order-number {
    margin-top: 24px;
    padding: 20px;
    border-radius: 12px;
    background: #f1f5f9;
    border: 1px solid #d6e3ed;
  }

  .order-number span {
    display: block;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
  }

  .order-number strong {
    display: block;
    margin-top: 8px;
    color: rgba(0, 0, 0, 0.82);
    font-size: 28px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  /* =========================================================
     Section
     ========================================================= */

  .section-head {
    margin-bottom: 24px;
  }

  .section-head h2 {
    margin: 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
  }

  .section-head p {
    margin: 8px 0 0;
    color: rgba(0, 0, 0, 0.55);
    font-size: 16px;
    font-weight: 400;
    line-height: 1.8;
    max-width: 820px;
  }

  /* =========================================================
     Inventory / History
     ========================================================= */

  .inventory-stock {
    color: rgba(0, 0, 0, 0.82);
    font-size: 32px;
    font-weight: 700;
    line-height: 1.5;
    text-align: right;
    font-variant-numeric: tabular-nums;
  }

  .history-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 16px;
    margin-top: 12px;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
  }

  .history-meta span {
    font-variant-numeric: tabular-nums;
  }

  .history-total {
    margin-top: 16px;
    color: rgba(0, 0, 0, 0.82);
    font-size: 28px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  .history-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 16px;
  }

  .detail-box {
    display: none;
    margin-top: 16px;
    padding: 16px;
    border-radius: 12px;
    background: #f1f5f9;
    border: 1px solid #d6e3ed;
  }

  .detail-box.is-open {
    display: block;
  }

  .detail-row {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 12px;
    padding: 8px 0;
    color: rgba(0, 0, 0, 0.82);
    font-size: 14px;
    font-weight: 400;
    line-height: 1.5;
    border-bottom: 1px solid #d6e3ed;
  }

  .detail-row:last-child {
    border-bottom: none;
  }

  .detail-row strong {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
  }

  /* =========================================================
     Dashboard
     ========================================================= */

  .dashboard-card h3 {
    margin: 0 0 16px;
    color: rgba(0, 0, 0, 0.82);
    font-size: 16px;
    font-weight: 700;
    line-height: 1.5;
  }

  .metric-value {
    color: rgba(0, 0, 0, 0.82);
    font-size: 38.4px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  .metric-label {
    margin-top: 8px;
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 400;
    line-height: 1.5;
  }

  /* =========================================================
     Bar Chart
     ========================================================= */

  .bar-chart {
    display: grid;
    gap: 12px;
  }

  .bar-row {
    display: grid;
    grid-template-columns: minmax(88px, 120px) 1fr minmax(72px, 96px);
    gap: 12px;
    align-items: center;
  }

  .bar-label {
    color: rgba(0, 0, 0, 0.55);
    font-size: 12px;
    font-weight: 600;
    line-height: 1.5;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .bar-track {
    height: 8px;
    border-radius: 999px;
    background: #f1f5f9;
    border: 1px solid #d6e3ed;
    overflow: hidden;
  }

  .bar-fill {
    height: 100%;
    border-radius: 999px;
    background: #3ea8ff;
    transition: width 0.45s ease;
  }

  .bar-value {
    text-align: right;
    color: rgba(0, 0, 0, 0.82);
    font-size: 12px;
    font-weight: 700;
    line-height: 1.5;
    font-variant-numeric: tabular-nums;
  }

  .empty {
    color: rgba(0, 0, 0, 0.55);
    font-size: 14px;
    font-weight: 400;
    line-height: 1.8;
    padding: 16px 0;
  }

  /* =========================================================
     Responsive
     ========================================================= */

  @media (min-width: 860px) {
    .app {
      grid-template-columns: 260px 1fr;
    }

    .sidebar {
      flex-direction: column;
      align-items: stretch;
      justify-content: flex-start;
      height: 100vh;
      padding: 24px 16px;
      border-right: 1px solid #d6e3ed;
      border-bottom: none;
      gap: 24px;
      background: #ffffff;
    }

    .sidebar-header {
      padding: 0 8px;
    }

    .nav {
      flex-direction: column;
      gap: 8px;
      overflow: visible;
    }

    .nav-button {
      width: 100%;
      justify-content: flex-start;
    }

    .main {
      padding: 48px 40px;
      background: #ffffff;
    }

    .layout {
      grid-template-columns: 1fr 380px;
      align-items: start;
      gap: 24px;
    }

    .product-grid {
      grid-template-columns: repeat(2, 1fr);
    }

    .dashboard-grid {
      grid-template-columns: repeat(3, 1fr);
    }

    .inventory-grid,
    .history-grid {
      grid-template-columns: repeat(2, 1fr);
    }
  }

  @media (min-width: 1180px) {
    .product-grid {
      grid-template-columns: repeat(3, 1fr);
    }

    .inventory-grid,
    .history-grid {
      grid-template-columns: repeat(3, 1fr);
    }
  }

  @media (max-width: 640px) {
    .sidebar {
      padding: 12px 16px;
      gap: 12px;
      align-items: flex-start;
      flex-direction: column;
    }

    .nav {
      width: 100%;
    }

    .main {
      padding: 24px 16px 40px;
    }

    .page-title,
    .success-title {
      font-size: 28px;
    }

    .page-lead,
    .section-head p {
      font-size: 15px;
      line-height: 1.8;
    }

    .product-card,
    .panel,
    .dashboard-card,
    .inventory-card,
    .history-card,
    .success-box {
      padding: 16px;
    }

    .total-value,
    .metric-value {
      font-size: 28px;
    }

    .bar-row {
      grid-template-columns: 1fr;
      gap: 4px;
    }

    .bar-value {
      text-align: left;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 0.01ms !important;
      transition-duration: 0.01ms !important;
    }
  }
</style>
  </head>

  <body>
    <div class="app">
      <!-- ============== Sidebar ============== -->
      <aside class="sidebar">
        <div class="sidebar-header">
          <div class="brand-text">
            <span class="brand-kicker">学校内イベントで使用するECサイト</span>
            <span class="brand-title">教材用</span>
          </div>
        </div>

        <nav class="nav">
          <button class="nav-button is-active" type="button" data-view="pos">
            <svg class="nav-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
              <path d="M2.5 4.5h11l-1 7.5h-9l-1-7.5z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>
              <path d="M5.5 4.5a2.5 2.5 0 015 0" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
            </svg>
            <span>販売</span>
          </button>

          <button class="nav-button" type="button" data-view="inventory">
            <svg class="nav-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
              <rect x="2.5" y="3.5" width="11" height="9" rx="1.5" stroke="currentColor" stroke-width="1.4"/>
              <path d="M2.5 6.5h11M6 9.5h4" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
            </svg>
            <span>在庫</span>
          </button>

          <button class="nav-button" type="button" data-view="history">
            <svg class="nav-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
              <circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.4"/>
              <path d="M8 5v3l2 1.5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
            </svg>
            <span>履歴</span>
          </button>

          <button class="nav-button" type="button" data-view="dashboard">
            <svg class="nav-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
              <path d="M3 12.5V8M7.5 12.5V5M12 12.5v-3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
            </svg>
            <span>分析</span>
          </button>
        </nav>
      </aside>

      <!-- ============== Main ============== -->
      <div class="main">
        <div class="main-inner">
          <main id="posView" class="view is-active">
            <header class="page-header">
              <p class="page-kicker">Point of Sale</p>
              <h1 class="page-title">グッズ販売受付</h1>
              <p class="page-lead">
                商品選択、現金決済、在庫管理、販売履歴、注文取消まで行えるイベント用POSです。
              </p>
            </header>

            <div class="layout">
              <section>
                <div id="productGrid" class="product-grid"></div>
              </section>

              <aside class="panel">
                <h2 class="cart-title">カート</h2>
                <div id="cartList" class="cart-list"></div>

                <div class="total-box">
                  <p class="total-label">注文合計</p>
                  <p id="cartTotal" class="total-value">¥0</p>
                </div>

                <div class="field">
                  <label for="receivedAmount">受取金額</label>
                  <input id="receivedAmount" type="number" min="0" placeholder="5000" />
                </div>

                <div id="changeAmount" class="change-box">お釣り:¥0</div>

                <div class="field">
                  <label for="staffName">販売担当者</label>
                  <input id="staffName" type="text" placeholder="山田" />
                </div>

                <div class="field">
                  <label for="customerName">購入者名(任意)</label>
                  <input id="customerName" type="text" placeholder="任意" />
                </div>

                <button id="submitOrderButton" class="primary-button" type="button">
                  現金決済を確定
                </button>

                <button id="clearCartButton" class="secondary-button" type="button">
                  カートを空にする
                </button>

                <div id="orderError" class="error"></div>
              </aside>
            </div>
          </main>

          <main id="successView" class="view">
            <div class="success-box">
              <h2 class="success-title">決済が完了しました</h2>
              <p class="success-description">
                注文内容をスプレッドシートに保存し、在庫を更新しました。
              </p>

              <div class="order-number">
                <span>注文番号</span>
                <strong id="completedOrderNumber">---</strong>
              </div>

              <div class="total-box">
                <p class="total-label">注文合計</p>
                <p id="completedOrderTotal" class="total-value">¥0</p>
              </div>

              <p
                id="completedPaymentText"
                style="
                  font-weight: 500;
                  letter-spacing: 0em;
                  color: var(--text-secondary);
                  font-size: 14px;
                  margin-top: 16px;
                  font-variant-numeric: tabular-nums;
                "
              ></p>

              <button id="nextOrderButton" class="primary-button" type="button">
                次の販売へ
              </button>
            </div>
          </main>

          <main id="inventoryView" class="view">
            <header class="page-header">
              <p class="page-kicker">Inventory</p>
              <h1 class="page-title">在庫管理</h1>
              <p class="page-lead">
                商品ごとの現在庫を確認し、追加納品・棚卸し修正などの理由を残して在庫を調整できます。
              </p>
            </header>

            <div id="inventoryGrid" class="inventory-grid"></div>
          </main>

          <main id="historyView" class="view">
            <header class="page-header">
              <p class="page-kicker">Order History</p>
              <h1 class="page-title">販売履歴</h1>
              <p class="page-lead">
                注文履歴の確認、注文明細の表示、注文取消を行えます。取消時は在庫が自動で戻ります。
              </p>
            </header>

            <div id="historyGrid" class="history-grid"></div>
          </main>

          <main id="dashboardView" class="view">
            <header class="page-header">
              <p class="page-kicker">Analytics</p>
              <h1 class="page-title">売上ダッシュボード</h1>
              <p class="page-lead">
                完了済み注文の集計と、商品・カテゴリ・担当者・時間帯別の傾向を一覧できます。
              </p>
            </header>

            <div class="dashboard-grid">
              <section class="dashboard-card">
                <h3>総売上</h3>
                <div id="totalSales" class="metric-value">¥0</div>
                <div class="metric-label">完了済み注文の合計</div>
              </section>

              <section class="dashboard-card">
                <h3>注文件数</h3>
                <div id="totalOrders" class="metric-value">0</div>
                <div class="metric-label">完了済み注文数</div>
              </section>

              <section class="dashboard-card">
                <h3>販売点数</h3>
                <div id="totalItems" class="metric-value">0</div>
                <div class="metric-label">販売した商品の合計点数</div>
              </section>

              <section class="dashboard-card">
                <h3>平均客単価</h3>
                <div id="averageOrderValue" class="metric-value">¥0</div>
                <div class="metric-label">1注文あたりの平均金額</div>
              </section>

              <section class="dashboard-card">
                <h3>売り切れ商品</h3>
                <div id="soldOutCount" class="metric-value">0</div>
                <div class="metric-label">現在庫0または売切の商品数</div>
              </section>

              <section class="dashboard-card">
                <h3>商品数</h3>
                <div id="productCount" class="metric-value">0</div>
                <div class="metric-label">商品マスタに登録済みの商品数</div>
              </section>

              <section class="dashboard-card">
                <h3>商品別販売数</h3>
                <div id="productSalesCountChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <h3>商品別売上</h3>
                <div id="productSalesAmountChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <h3>カテゴリ別売上</h3>
                <div id="categorySalesAmountChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <h3>担当者別売上</h3>
                <div id="staffSalesAmountChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <h3>時間帯別売上</h3>
                <div id="hourlySalesAmountChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <h3>在庫残数</h3>
                <div id="inventoryStockChart" class="bar-chart"></div>
              </section>
            </div>
          </main>
        </div>
      </div>
    </div>

    <script>
  var products = [];

  var cart = [];

  var inventoryItems = [];

  var orderHistoryItems = [];



  var productGrid = document.getElementById('productGrid');

  var cartList = document.getElementById('cartList');

  var cartTotal = document.getElementById('cartTotal');

  var receivedAmountInput = document.getElementById('receivedAmount');

  var changeAmount = document.getElementById('changeAmount');

  var staffNameInput = document.getElementById('staffName');

  var customerNameInput = document.getElementById('customerName');

  var orderError = document.getElementById('orderError');

  var submitOrderButton = document.getElementById('submitOrderButton');



  function initializeApp() {

    bindNavigation();

    bindEvents();

    bindDynamicActions();

    loadProducts();

  }



  function isGoogleScriptReady() {

    return typeof google !== 'undefined' && google.script && google.script.run;

  }



  function bindNavigation() {

    var buttons = document.querySelectorAll('.nav-button');



    for (var i = 0; i < buttons.length; i++) {

      buttons[i].addEventListener('click', function () {

        var view = this.getAttribute('data-view');



        setActiveNav(view);

        showView(view);



        if (view === 'pos') {

          loadProducts();

        }



        if (view === 'inventory') {

          loadInventory();

        }



        if (view === 'history') {

          loadOrderHistory();

        }



        if (view === 'dashboard') {

          loadDashboard();

        }

      });

    }

  }



  function bindEvents() {

    receivedAmountInput.addEventListener('input', renderCart);



    document.getElementById('clearCartButton').addEventListener('click', function () {

      cart = [];

      hideOrderError();

      renderCart();

    });



    submitOrderButton.addEventListener('click', submitOrder);



    document.getElementById('nextOrderButton').addEventListener('click', function () {

      cart = [];

      receivedAmountInput.value = '';

      customerNameInput.value = '';

      hideOrderError();

      renderCart();

      loadProducts();

      setActiveNav('pos');

      showView('pos');

    });

  }



  function bindDynamicActions() {

    document.body.addEventListener('click', function (event) {

      var button = event.target.closest('[data-action]');



      if (!button) {

        return;

      }



      var action = button.getAttribute('data-action');



      if (action === 'add-to-cart') {

        addToCart(button.getAttribute('data-product-id'));

      }



      if (action === 'increment') {

        incrementQuantity(button.getAttribute('data-product-id'));

      }



      if (action === 'decrement') {

        decrementQuantity(button.getAttribute('data-product-id'));

      }



      if (action === 'adjust-inventory') {

        submitInventoryAdjustment(button);

      }



      if (action === 'toggle-detail') {

        toggleOrderDetails(button.getAttribute('data-order-number'));

      }



      if (action === 'cancel-order') {

        startCancelOrder(button.getAttribute('data-order-number'));

      }

    });

  }



  function setActiveNav(viewName) {

    var buttons = document.querySelectorAll('.nav-button');



    for (var i = 0; i < buttons.length; i++) {

      buttons[i].classList.toggle(

        'is-active',

        buttons[i].getAttribute('data-view') === viewName

      );

    }

  }



  function showView(viewName) {

    var viewIds = {

      pos: 'posView',

      success: 'successView',

      inventory: 'inventoryView',

      history: 'historyView',

      dashboard: 'dashboardView'

    };



    var views = document.querySelectorAll('.view');



    for (var i = 0; i < views.length; i++) {

      views[i].classList.remove('is-active');

    }



    var targetId = viewIds[viewName];

    var target = targetId ? document.getElementById(targetId) : null;



    if (target) {

      target.classList.add('is-active');

    }

  }



  function loadProducts() {

    productGrid.innerHTML = '<div class="empty">商品を読み込み中です...</div>';



    if (!isGoogleScriptReady()) {

      productGrid.innerHTML =

        '<div class="error is-visible">google.script.run が使えません。WebアプリURLから開いてください。</div>';

      return;

    }



    google.script.run

      .withSuccessHandler(function (result) {

        products = result || [];

        syncCartStock();

        renderProducts();

        renderCart();

      })

      .withFailureHandler(function (error) {

        productGrid.innerHTML =

          '<div class="error is-visible">' +

          escapeHtml(error.message || '商品取得に失敗しました。') +

          '</div>';

      })

      .getProducts();

  }



  function syncCartStock() {

    var nextCart = [];



    for (var i = 0; i < cart.length; i++) {

      var cartItem = cart[i];

      var latestProduct = null;



      for (var j = 0; j < products.length; j++) {

        if (products[j].productId === cartItem.productId) {

          latestProduct = products[j];

          break;

        }

      }



      if (latestProduct && latestProduct.isAvailable) {

        nextCart.push({

          productId: latestProduct.productId,

          name: latestProduct.name,

          price: latestProduct.price,

          currentStock: latestProduct.currentStock,

          quantity: Math.min(cartItem.quantity, latestProduct.currentStock)

        });

      }

    }



    cart = nextCart;

  }



  function renderProducts() {

    if (!products || products.length === 0) {

      productGrid.innerHTML = '<div class="empty">商品が登録されていません。</div>';

      return;

    }



    var html = '';



    for (var i = 0; i < products.length; i++) {

      var product = products[i];

      var disabled = !product.isAvailable;



      var stockClass = 'stock-badge';



      if (product.isSoldOut) {

        stockClass = 'stock-badge is-soldout';

      } else if (product.currentStock <= 5) {

        stockClass = 'stock-badge is-warning';

      }



      var stockText = '在庫 ' + product.currentStock;



      if (product.isSoldOut) {

        stockText = '売切';

      } else if (product.currentStock <= 5) {

        stockText = '残り ' + product.currentStock;

      }



      html +=

        '<article class="product-card">' +

          '<img class="product-image" src="' +

            escapeHtml(product.imageUrl || 'https://placehold.co/600x400?text=No+Image') +

            '" alt="' +

            escapeHtml(product.name) +

            '" onerror="this.src=\'https://placehold.co/600x400?text=No+Image\';" />' +



          '<div class="product-meta">' +

            '<div>' +

              '<h2 class="product-name">' + escapeHtml(product.name) + '</h2>' +

              '<p class="product-category">' + escapeHtml(product.category) + '</p>' +

            '</div>' +

            '<div class="product-price">' + formatCurrency(product.price) + '</div>' +

          '</div>' +



          '<div class="stock-row">' +

            '<span>' + escapeHtml(product.status) + '</span>' +

            '<span class="' + stockClass + '">' + escapeHtml(stockText) + '</span>' +

          '</div>' +



          '<button class="add-button" type="button" data-action="add-to-cart" data-product-id="' +

            escapeHtml(product.productId) +

            '" ' +

            (disabled ? 'disabled' : '') +

          '>' +

            (disabled ? '販売不可' : 'カートに追加') +

          '</button>' +

        '</article>';

    }



    productGrid.innerHTML = html;

  }



  function addToCart(productId) {

    var product = null;



    for (var i = 0; i < products.length; i++) {

      if (products[i].productId === productId) {

        product = products[i];

        break;

      }

    }



    if (!product || !product.isAvailable) {

      return;

    }



    var existingItem = null;



    for (var j = 0; j < cart.length; j++) {

      if (cart[j].productId === productId) {

        existingItem = cart[j];

        break;

      }

    }



    if (existingItem) {

      if (existingItem.quantity >= product.currentStock) {

        showOrderError('在庫数を超えて追加できません。');

        return;

      }



      existingItem.quantity += 1;

    } else {

      cart.push({

        productId: product.productId,

        name: product.name,

        price: product.price,

        currentStock: product.currentStock,

        quantity: 1

      });

    }



    hideOrderError();

    renderCart();

  }



  function incrementQuantity(productId) {

    var item = findCartItem(productId);



    if (!item) {

      return;

    }



    if (item.quantity >= item.currentStock) {

      showOrderError('在庫数を超えて追加できません。');

      return;

    }



    item.quantity += 1;

    hideOrderError();

    renderCart();

  }



  function decrementQuantity(productId) {

    var item = findCartItem(productId);



    if (!item) {

      return;

    }



    item.quantity -= 1;



    if (item.quantity <= 0) {

      var nextCart = [];



      for (var i = 0; i < cart.length; i++) {

        if (cart[i].productId !== productId) {

          nextCart.push(cart[i]);

        }

      }



      cart = nextCart;

    }



    hideOrderError();

    renderCart();

  }



  function findCartItem(productId) {

    for (var i = 0; i < cart.length; i++) {

      if (cart[i].productId === productId) {

        return cart[i];

      }

    }



    return null;

  }



  function renderCart() {

    if (cart.length === 0) {

      cartList.innerHTML = '<div class="empty">カートは空です。</div>';

    } else {

      var html = '';



      for (var i = 0; i < cart.length; i++) {

        var item = cart[i];



        html +=

          '<div class="cart-row">' +

            '<div>' +

              '<p class="cart-name">' + escapeHtml(item.name) + '</p>' +

              '<p class="cart-sub">' +

                formatCurrency(item.price) +

                ' × ' +

                item.quantity +

                ' = ' +

                formatCurrency(item.price * item.quantity) +

              '</p>' +

            '</div>' +



            '<div class="qty-control">' +

              '<button class="qty-button" type="button" data-action="decrement" data-product-id="' +

                escapeHtml(item.productId) +

              '">−</button>' +

              '<span class="qty-value">' + item.quantity + '</span>' +

              '<button class="qty-button" type="button" data-action="increment" data-product-id="' +

                escapeHtml(item.productId) +

              '">+</button>' +

            '</div>' +

          '</div>';

      }



      cartList.innerHTML = html;

    }



    var total = getCartTotal();

    var receivedAmount = Number(receivedAmountInput.value || 0);

    var change = Math.max(0, receivedAmount - total);



    cartTotal.textContent = formatCurrency(total);

    changeAmount.textContent = 'お釣り:' + formatCurrency(change);

  }



  function submitOrder() {

    hideOrderError();



    var total = getCartTotal();

    var receivedAmount = Number(receivedAmountInput.value || 0);

    var staffName = staffNameInput.value.trim();



    if (cart.length === 0) {

      showOrderError('商品を選択してください。');

      return;

    }



    if (!staffName) {

      showOrderError('販売担当者を入力してください。');

      return;

    }



    if (receivedAmount < total) {

      showOrderError('受取金額が注文合計より少ないです。');

      return;

    }



    submitOrderButton.disabled = true;

    submitOrderButton.textContent = '決済処理中...';



    var items = [];



    for (var i = 0; i < cart.length; i++) {

      items.push({

        productId: cart[i].productId,

        quantity: cart[i].quantity

      });

    }



    google.script.run

      .withSuccessHandler(function (result) {

        submitOrderButton.disabled = false;

        submitOrderButton.textContent = '現金決済を確定';

        showCompletedOrder(result);

      })

      .withFailureHandler(function (error) {

        submitOrderButton.disabled = false;

        submitOrderButton.textContent = '現金決済を確定';

        showOrderError(error.message || '決済に失敗しました。');

        loadProducts();

      })

      .submitOrder({

        items: items,

        staffName: staffName,

        customerName: customerNameInput.value.trim(),

        receivedAmount: receivedAmount,

        userAgent: window.navigator.userAgent

      });

  }



  function showCompletedOrder(result) {

    document.getElementById('completedOrderNumber').textContent = result.orderNumber;

    document.getElementById('completedOrderTotal').textContent = formatCurrency(result.orderTotal);



    document.getElementById('completedPaymentText').textContent =

      '受取金額:' +

      formatCurrency(result.receivedAmount) +

      ' / お釣り:' +

      formatCurrency(result.changeAmount);



    showView('success');

    setActiveNav('');

  }



  function getCartTotal() {

    var total = 0;



    for (var i = 0; i < cart.length; i++) {

      total += cart[i].price * cart[i].quantity;

    }



    return total;

  }



  function loadInventory() {

    var inventoryGrid = document.getElementById('inventoryGrid');

    inventoryGrid.innerHTML = '<div class="empty">在庫情報を読み込み中です...</div>';



    google.script.run

      .withSuccessHandler(function (result) {

        inventoryItems = result || [];

        renderInventory();

      })

      .withFailureHandler(function (error) {

        inventoryGrid.innerHTML =

          '<div class="error is-visible">' +

          escapeHtml(error.message || '在庫情報の取得に失敗しました。') +

          '</div>';

      })

      .getInventoryData();

  }



  function renderInventory() {

    var inventoryGrid = document.getElementById('inventoryGrid');



    if (!inventoryItems || inventoryItems.length === 0) {

      inventoryGrid.innerHTML = '<div class="empty">商品が登録されていません。</div>';

      return;

    }



    var html = '';



    for (var i = 0; i < inventoryItems.length; i++) {

      var item = inventoryItems[i];



      var statusText = item.isSoldOut ? '売切' : item.status;

      var badgeClass = 'stock-badge';



      if (item.isSoldOut) {

        badgeClass = 'stock-badge is-soldout';

      } else if (item.currentStock <= 5) {

        badgeClass = 'stock-badge is-warning';

      }



      html +=

        '<article class="inventory-card" data-product-id="' + escapeHtml(item.productId) + '">' +

          '<div class="inventory-card-header">' +

            '<div>' +

              '<h3 class="inventory-title">' + escapeHtml(item.name) + '</h3>' +

              '<p class="inventory-sub">' +

                escapeHtml(item.category) +

                ' / ' +

                formatCurrency(item.price) +

              '</p>' +

            '</div>' +



            '<div>' +

              '<div class="inventory-stock">' + item.currentStock + '</div>' +

              '<div style="margin-top: 8px; text-align: right;">' +

                '<span class="' + badgeClass + '">' + escapeHtml(statusText) + '</span>' +

              '</div>' +

            '</div>' +

          '</div>' +



          '<div class="history-meta">' +

            '<span>初期在庫 ' + item.initialStock + '</span>' +

            '<span>販売数 ' + item.soldCount + '</span>' +

          '</div>' +



          '<div class="inline-field">' +

            '<label>新しい在庫数</label>' +

            '<input class="inventory-new-stock" type="number" min="0" value="' + item.currentStock + '" />' +

          '</div>' +



          '<div class="inline-field">' +

            '<label>調整理由</label>' +

            '<input class="inventory-reason" type="text" placeholder="追加納品 / 棚卸し修正" />' +

          '</div>' +



          '<div class="inline-field">' +

            '<label>担当者</label>' +

            '<input class="inventory-staff" type="text" placeholder="山田" />' +

          '</div>' +



          '<button class="small-button" type="button" data-action="adjust-inventory">在庫を更新</button>' +

        '</article>';

    }



    inventoryGrid.innerHTML = html;

  }



  function submitInventoryAdjustment(button) {

    var card = button.closest('.inventory-card');



    if (!card) {

      return;

    }



    var productId = card.getAttribute('data-product-id');

    var newStock = card.querySelector('.inventory-new-stock').value;

    var reason = card.querySelector('.inventory-reason').value;

    var staffName = card.querySelector('.inventory-staff').value;



    if (!String(reason || '').trim()) {

      alert('調整理由を入力してください。');

      return;

    }



    if (!String(staffName || '').trim()) {

      alert('担当者を入力してください。');

      return;

    }



    button.disabled = true;

    button.textContent = '更新中...';



    google.script.run

      .withSuccessHandler(function (result) {

        button.disabled = false;

        button.textContent = '在庫を更新';

        alert(result.message || '在庫を更新しました。');

        loadInventory();

        loadProducts();

      })

      .withFailureHandler(function (error) {

        button.disabled = false;

        button.textContent = '在庫を更新';

        alert(error.message || '在庫更新に失敗しました。');

      })

      .adjustInventory({

        productId: productId,

        newStock: newStock,

        reason: reason,

        staffName: staffName

      });

  }



  function loadOrderHistory() {

    var historyGrid = document.getElementById('historyGrid');

    historyGrid.innerHTML = '<div class="empty">販売履歴を読み込み中です...</div>';



    google.script.run

      .withSuccessHandler(function (result) {

        orderHistoryItems = result || [];

        renderOrderHistory();

      })

      .withFailureHandler(function (error) {

        historyGrid.innerHTML =

          '<div class="error is-visible">' +

          escapeHtml(error.message || '販売履歴の取得に失敗しました。') +

          '</div>';

      })

      .getOrderHistory();

  }



  function renderOrderHistory() {

    var historyGrid = document.getElementById('historyGrid');



    if (!orderHistoryItems || orderHistoryItems.length === 0) {

      historyGrid.innerHTML = '<div class="empty">販売履歴がありません。</div>';

      return;

    }



    var html = '';



    for (var i = 0; i < orderHistoryItems.length; i++) {

      var order = orderHistoryItems[i];

      var isCanceled = order.status === '取消';



      var customerHtml = order.customerName

        ? '<span>購入者 ' + escapeHtml(order.customerName) + '</span>'

        : '';



      var cancelButtonHtml = isCanceled

        ? ''

        : '<button class="small-button danger" type="button" data-action="cancel-order" data-order-number="' +

          escapeHtml(order.orderNumber) +

          '">注文を取消</button>';



      html +=

        '<article class="history-card">' +

          '<div class="history-card-header">' +

            '<div>' +

              '<h3 class="history-title">' + escapeHtml(order.orderNumber) + '</h3>' +

              '<p class="history-sub">' + escapeHtml(order.orderedAt) + '</p>' +

            '</div>' +

            '<span class="stock-badge ' + (isCanceled ? 'is-soldout' : '') + '">' +

              escapeHtml(order.status) +

            '</span>' +

          '</div>' +



          '<div class="history-total">' + formatCurrency(order.orderTotal) + '</div>' +



          '<div class="history-meta">' +

            '<span>受取 ' + formatCurrency(order.receivedAmount) + '</span>' +

            '<span>お釣り ' + formatCurrency(order.changeAmount) + '</span>' +

            '<span>担当 ' + escapeHtml(order.staffName || '未入力') + '</span>' +

            customerHtml +

          '</div>' +



          '<div class="history-actions">' +

            '<button class="small-button secondary" type="button" data-action="toggle-detail" data-order-number="' +

              escapeHtml(order.orderNumber) +

            '">明細を見る</button>' +

            cancelButtonHtml +

          '</div>' +



          '<div class="detail-box" data-detail-box="' + escapeHtml(order.orderNumber) + '"></div>' +

        '</article>';

    }



    historyGrid.innerHTML = html;

  }



  function toggleOrderDetails(orderNumber) {

    var detailBoxes = document.querySelectorAll('[data-detail-box]');

    var detailBox = null;



    for (var i = 0; i < detailBoxes.length; i++) {

      if (detailBoxes[i].getAttribute('data-detail-box') === orderNumber) {

        detailBox = detailBoxes[i];

        break;

      }

    }



    if (!detailBox) {

      return;

    }



    if (detailBox.classList.contains('is-open')) {

      detailBox.classList.remove('is-open');

      detailBox.innerHTML = '';

      return;

    }



    detailBox.classList.add('is-open');

    detailBox.innerHTML = '<div class="empty">明細を読み込み中です...</div>';



    google.script.run

      .withSuccessHandler(function (details) {

        if (!details || details.length === 0) {

          detailBox.innerHTML = '<div class="empty">明細がありません。</div>';

          return;

        }



        var html = '';



        for (var i = 0; i < details.length; i++) {

          var detail = details[i];



          html +=

            '<div class="detail-row">' +

              '<span>' + escapeHtml(detail.name) + ' × ' + detail.quantity + '</span>' +

              '<strong>' + formatCurrency(detail.subtotal) + '</strong>' +

            '</div>';

        }



        detailBox.innerHTML = html;

      })

      .withFailureHandler(function (error) {

        detailBox.innerHTML =

          '<div class="error is-visible">' +

          escapeHtml(error.message || '明細取得に失敗しました。') +

          '</div>';

      })

      .getOrderDetails(orderNumber);

  }



  function startCancelOrder(orderNumber) {

    var reason = window.prompt('取消理由を入力してください。例:入力ミス');

    if (!reason) {

      return;

    }



    var staffName = window.prompt('取消担当者を入力してください。');

    if (!staffName) {

      return;

    }



    var confirmed = window.confirm(

      '注文 ' + orderNumber + ' を取消します。\n在庫も自動で戻します。\nよろしいですか?'

    );



    if (!confirmed) {

      return;

    }



    google.script.run

      .withSuccessHandler(function (result) {

        alert(result.message || '注文を取消しました。');

        loadOrderHistory();

        loadProducts();

      })

      .withFailureHandler(function (error) {

        alert(error.message || '注文取消に失敗しました。');

      })

      .cancelOrder({

        orderNumber: orderNumber,

        reason: reason,

        staffName: staffName

      });

  }



  function loadDashboard() {

    google.script.run

      .withSuccessHandler(function (data) {

        renderDashboard(data);

      })

      .withFailureHandler(function (error) {

        alert(error.message || 'ダッシュボードの読み込みに失敗しました。');

      })

      .getDashboardData();

  }



  function renderDashboard(data) {

    document.getElementById('totalSales').textContent = formatCurrency(data.totalSales);

    document.getElementById('totalOrders').textContent = Number(data.totalOrders || 0);

    document.getElementById('totalItems').textContent = Number(data.totalItems || 0);

    document.getElementById('averageOrderValue').textContent = formatCurrency(data.averageOrderValue);

    document.getElementById('soldOutCount').textContent = Number(data.soldOutCount || 0);

    document.getElementById('productCount').textContent = Array.isArray(data.products) ? data.products.length : 0;



    renderBarChart('productSalesCountChart', data.productSalesCounts || {}, '点', false);

    renderBarChart('productSalesAmountChart', data.productSalesAmounts || {}, '円', true);

    renderBarChart('categorySalesAmountChart', data.categorySalesAmounts || {}, '円', true);

    renderBarChart('staffSalesAmountChart', data.staffSalesAmounts || {}, '円', true);

    renderBarChart('hourlySalesAmountChart', filterActiveCounts(data.hourlySalesAmounts || {}), '円', true);



    var inventoryCounts = {};



    if (data.products) {

      for (var i = 0; i < data.products.length; i++) {

        inventoryCounts[data.products[i].name] = data.products[i].currentStock;

      }

    }



    renderBarChart('inventoryStockChart', inventoryCounts, '点', false);

  }



  function filterActiveCounts(counts) {

    var filtered = {};



    for (var label in counts) {

      if (Object.prototype.hasOwnProperty.call(counts, label)) {

        if (Number(counts[label]) > 0) {

          filtered[label] = counts[label];

        }

      }

    }



    return filtered;

  }



  function renderBarChart(elementId, counts, suffix, isCurrency) {

    var element = document.getElementById(elementId);

    var entries = [];



    for (var label in counts) {

      if (Object.prototype.hasOwnProperty.call(counts, label)) {

        if (Number(counts[label]) > 0) {

          entries.push([label, counts[label]]);

        }

      }

    }



    entries.sort(function (a, b) {

      return Number(b[1]) - Number(a[1]);

    });



    entries = entries.slice(0, 8);



    if (entries.length === 0) {

      element.innerHTML = '<div class="empty">まだデータがありません。</div>';

      return;

    }



    var maxValue = 0;



    for (var i = 0; i < entries.length; i++) {

      maxValue = Math.max(maxValue, Number(entries[i][1]));

    }



    var html = '';



    for (var j = 0; j < entries.length; j++) {

      var numericValue = Number(entries[j][1]);

      var width = maxValue === 0 ? 0 : Math.round((numericValue / maxValue) * 100);

      var displayValue = isCurrency

        ? formatCurrency(numericValue)

        : String(numericValue) + (suffix || '');



      html +=

        '<div class="bar-row">' +

          '<div class="bar-label" title="' + escapeHtml(entries[j][0]) + '">' +

            escapeHtml(entries[j][0]) +

          '</div>' +

          '<div class="bar-track">' +

            '<div class="bar-fill" style="width: ' + width + '%"></div>' +

          '</div>' +

          '<div class="bar-value">' + displayValue + '</div>' +

        '</div>';

    }



    element.innerHTML = html;

  }



  function showOrderError(message) {

    orderError.textContent = message;

    orderError.classList.add('is-visible');

  }



  function hideOrderError() {

    orderError.textContent = '';

    orderError.classList.remove('is-visible');

  }



  function formatCurrency(value) {

    return '¥' + Number(value || 0).toLocaleString('ja-JP');

  }



  function escapeHtml(value) {

    return String(value || '')

      .replace(/&/g, '&amp;')

      .replace(/</g, '&lt;')

      .replace(/>/g, '&gt;')

      .replace(/"/g, '&quot;')

      .replace(/'/g, '&#039;');

  }



  try {

    initializeApp();

  } catch (error) {

    console.error('初期化エラー:', error);



    if (productGrid) {

      productGrid.innerHTML =

        '<div class="error is-visible">画面初期化に失敗しました。Consoleを確認してください。</div>';

    }

  }

</script>
  </body>
</html>

5. 貼り付けるコードを間違えない

ここが一番大事です。

貼る場所貼るコード
Code.gsconst PRODUCT_SHEET_NAME = '商品マスタ'; から始まるコード
index.html<!DOCTYPE html> から始まるコード

目印はこれです。

Code.gs の目印

const PRODUCT_SHEET_NAME = '商品マスタ';

これが先頭にあるコードは、Code.gs に貼ります。

index.html の目印

<!DOCTYPE html>
<html lang="ja">

これが先頭にあるコードは、index.html に貼ります。


6. 保存する

コードを貼り付けたら、必ず保存します。

画面上の保存ボタンを押します。

または、キーボードで保存できます。

Windows:Ctrl + S
Mac:Command + S

保存できると、Apps Script の上の方にある未保存マークが消えます。

ここまでできたら、かなり進んでいます。


7. まだ実行しなくてOK

この時点では、まだアプリを実行しなくて大丈夫です。

今回の作業は、

完成コードを正しい場所に貼る

ことです。

次の節で、

setupApp()

を実行して、必要なシートを自動で作ります。


8. ここまでできたかチェック

次のチェックができていればOKです。

□ スプレッドシートを開いた
□ 拡張機能 → Apps Script を開いた
□ Code.gs を開いた
□ Code.gs の中身を全部消した
□ Code.gs 用コードを貼り付けた
□ index.html を開いた
□ index.html の中身を全部消した
□ index.html 用コードを貼り付けた
□ 保存した

全部できていれば、第3節はクリアです。


よくある失敗

よくある失敗対応
Code.gs に HTML を貼ってしまった<!DOCTYPE html> から始まるものは index.html へ
index.html に GASコードを貼ってしまったconst PRODUCT_SHEET_NAME から始まるものは Code.gs へ
保存し忘れたCtrl + S または Command + S
index.html がない左側の「+」から HTML を追加して、名前を index にする
ファイル名を index.html.html にした名前は index だけでOK

ここで少しだけ仕組みを理解しよう

今貼り付けた2つのファイルは、役割が違います。

index.html
↓
見える画面を作る

Code.gs
↓
裏側でデータを保存する

たとえば、販売画面で「現金決済を確定」を押すと、index.html から Code.gs にお願いが送られます。

そして、Code.gs がスプレッドシートに注文内容を保存します。

流れはこうです。

画面でボタンを押す
↓
index.html が反応する
↓
Code.gs に処理をお願いする
↓
スプレッドシートに保存する

この流れだけ覚えれば、今は十分です。


今日のひとこと

コードが長いと、最初はびっくりします。

でも、今は全部を読む必要はありません。

まずは、

正しい場所に貼る
保存する

ここだけできればOKです。

完成コードを先に動かしてから、あとで少しずつ中身を見ていきます。

アプリ制作は、最初から完璧に理解するより、動くものを触りながら覚える方が続きやすいです。


ミニ確認

Q1. Code.gs には何を貼りますか?

回答

const PRODUCT_SHEET_NAME = '商品マスタ'; から始まる、裏側の処理コードを貼ります。


Q2. index.html には何を貼りますか?

回答

<!DOCTYPE html> から始まる、画面用のHTMLコードを貼ります。


Q3. 貼り付けたあとに必ずやることは何ですか?

回答

保存します。


Q4. 今回はまだ setupApp() を実行しますか?

回答

まだ実行しません。

次の節で実行します。


まとめ

今回は、完成アプリのコードをApps Scriptに入れました。

やったことはこれです。

Code.gs に裏側のコードを貼った
index.html に画面のコードを貼った
保存した

これで、アプリの材料が入りました。

次は、setupApp() を実行して、商品マスタや注文サマリーなどのシートを自動で作ります。

教材トップへ戻る