Code.gsとindex.htmlを貼り付ける
この時間でやること
今回は、前回作った 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
try {
initializeApp();
} catch (error) {
console.error('初期化エラー:', error);
if (productGrid) {
productGrid.innerHTML =
'<div class="error is-visible">画面初期化に失敗しました。Consoleを確認してください。</div>';
}
}
</script>
</body>
</html>
5. 貼り付けるコードを間違えない
ここが一番大事です。
| 貼る場所 | 貼るコード |
|---|---|
| Code.gs | const 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() を実行して、商品マスタや注文サマリーなどのシートを自動で作ります。
