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

まずは完成コードをコピペする

4学校祭デジタル受付アプリを作る
意思決定AI活用ChatGPTGeminiClaude

この節で学ぶこと

前の節では、Code.gsindex.html の役割を学びました。

この節では、実際に完成コードをGoogle Apps Scriptへ貼り付けます。

今回の授業では、最初からすべてのコードを自分で書くのではなく、まず完成しているコードをコピーして貼り付けます。

完成コードをコピーする
↓
Code.gsに貼り付ける
↓
index.htmlを作る
↓
index.htmlに貼り付ける
↓
保存する

プログラム初心者にとって大切なのは、まず動く状態を作ることです。

コードの意味は、動かしたあとに少しずつ見ていきます。


なぜ最初はコピペでよいのか

プログラミング初心者が、最初から長いコードをすべて手入力すると、かなり高い確率でエラーになります。

たとえば、次のような小さなミスでも動かなくなります。

カッコが1つ足りない
セミコロンが抜けている
全角スペースが入っている
ファイル名が違う
大文字と小文字が違う

そのため、今回はまず完成コードをそのまま貼り付けます。

そのあとで、

ここを変えるとタイトルが変わる
ここを変えるとボタンの色が変わる
ここを変えると管理者パスコードが変わる

というように、少しずつカスタマイズします。


1. この節でやること

この節で行う作業は、次の4つです。

順番作業
1Code.gs の中身を消す
2完成版の Code.gs を貼り付ける
3index.html を新しく作る
4完成版の index.html を貼り付ける

まだ、アプリの実行や公開はしません。

実行は次の節で行います。


2. Code.gsに完成コードを貼り付ける

手順

Google Apps Scriptの画面を開きます。

左側のファイル一覧に、Code.gs があることを確認します。

1. 左側の Code.gs をクリックする
2. 中央のコードをすべて選択する
3. 削除する
4. 完成版の Code.gs を貼り付ける
5. 保存する

コピーするプログラム(Code.gs)

const SHEET_NAME = '受付データ';
const AREA_MASTER_SHEET_NAME = '地域マスタ';
const ADMIN_PASSCODE_PROPERTY_KEY = 'ADMIN_PASSCODE';
const TARGET_VISITOR_COUNT_PROPERTY_KEY = 'TARGET_VISITOR_COUNT';
const DEFAULT_TARGET_VISITOR_COUNT = 4;

/**
 * 役割: WebアプリにアクセスされたときにHTML画面を返す
 * 入力: e - URLパラメータを含むイベントオブジェクト
 * 出力: HtmlOutput - 表示用HTML
 */
function doGet(e) {
  const template = HtmlService.createTemplateFromFile('index');

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

  return template
    .evaluate()
    .setTitle('学校祭デジタル受付')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

/**
 * 役割: 初期設定として受付シート、地域マスタ、管理者パスコード、目標人数を設定する
 * 入力: なし
 * 出力: なし
 */
function setupApp() {
  setupSheet();
  setupAreaMasterSheet();
  setAdminPasscode();
  setDefaultTargetVisitorCount();
}

/**
 * 役割: 管理者用パスコードをスクリプトプロパティに保存する
 * 入力: なし
 * 出力: なし
 */
function setAdminPasscode() {
  const passcode = '2026';

  PropertiesService
    .getScriptProperties()
    .setProperty(ADMIN_PASSCODE_PROPERTY_KEY, passcode);
}

/**
 * 役割: 初期目標来場者数をスクリプトプロパティに保存する
 * 入力: なし
 * 出力: なし
 */
function setDefaultTargetVisitorCount() {
  const savedTargetCount = PropertiesService
    .getScriptProperties()
    .getProperty(TARGET_VISITOR_COUNT_PROPERTY_KEY);

  if (!savedTargetCount) {
    PropertiesService
      .getScriptProperties()
      .setProperty(
        TARGET_VISITOR_COUNT_PROPERTY_KEY,
        String(DEFAULT_TARGET_VISITOR_COUNT)
      );
  }
}

/**
 * 役割: 管理者パスコードが正しいか検証する
 * 入力: passcode - 管理者が入力したパスコード
 * 出力: boolean - 正しければtrue
 */
function isValidAdminPasscode(passcode) {
  const savedPasscode = PropertiesService
    .getScriptProperties()
    .getProperty(ADMIN_PASSCODE_PROPERTY_KEY);

  if (!savedPasscode) {
    throw new Error(
      '管理者パスコードが未設定です。setupAppまたはsetAdminPasscodeを先に実行してください。'
    );
  }

  return String(passcode || '') === savedPasscode;
}

/**
 * 役割: 管理者パスコードを検証し、管理画面用の集計データを返す
 * 入力: passcode - 管理者が入力したパスコード
 * 出力: object - 管理画面用の集計データ
 */
function getDashboardDataSecure(passcode) {
  if (!isValidAdminPasscode(passcode)) {
    throw new Error('管理者パスコードが正しくありません。');
  }

  return getDashboardData();
}

/**
 * 役割: 初回利用時に受付データ用シートを作成または見出しを整える
 * 入力: なし
 * 出力: Sheet - 受付データ用のシート
 */
function setupSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(SHEET_NAME);

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

  const headers = [
    '受付日時',
    '受付番号',
    '氏名カナ',
    '来場区分',
    '学年',
    'クラス',
    '来場目的',
    '同伴人数',
    '郵便番号',
    '都道府県',
    '市区町村',
    '住所詳細',
    '出身学校',
    '備考',
    'UserAgent',
  ];

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

  return sheet;
}

/**
 * 役割: 受付データ用のシートを取得する。存在しない場合は作成する
 * 入力: なし
 * 出力: Sheet - 受付データ用のシート
 */
function getReceptionSheet() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(SHEET_NAME);

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

  return sheet;
}

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

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

  const headers = ['都道府県', '市区町村'];

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

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

  const initialRows = [
    ['北海道', '札幌市'], ['北海道', '函館市'], ['北海道', '旭川市'],
    ['青森県', '青森市'], ['青森県', '弘前市'], ['青森県', '八戸市'],
    ['岩手県', '盛岡市'], ['岩手県', '一関市'], ['岩手県', '奥州市'],
    ['宮城県', '仙台市'], ['宮城県', '石巻市'], ['宮城県', '大崎市'],
    ['秋田県', '秋田市'], ['秋田県', '横手市'], ['秋田県', '大仙市'],
    ['山形県', '山形市'], ['山形県', '米沢市'], ['山形県', '鶴岡市'],
    ['福島県', '福島市'], ['福島県', '郡山市'], ['福島県', 'いわき市'],
    ['茨城県', '水戸市'], ['茨城県', 'つくば市'], ['茨城県', '日立市'],
    ['栃木県', '宇都宮市'], ['栃木県', '小山市'], ['栃木県', '栃木市'],
    ['群馬県', '前橋市'], ['群馬県', '高崎市'], ['群馬県', '太田市'],
    ['埼玉県', 'さいたま市'], ['埼玉県', '川口市'], ['埼玉県', '川越市'],
    ['千葉県', '千葉市'], ['千葉県', '船橋市'], ['千葉県', '柏市'],
    ['東京都', '千代田区'], ['東京都', '中央区'], ['東京都', '港区'], ['東京都', '新宿区'], ['東京都', '渋谷区'], ['東京都', '世田谷区'], ['東京都', '八王子市'],
    ['神奈川県', '横浜市'], ['神奈川県', '川崎市'], ['神奈川県', '相模原市'], ['神奈川県', '藤沢市'],
    ['新潟県', '新潟市'], ['新潟県', '長岡市'], ['新潟県', '上越市'],
    ['富山県', '富山市'], ['富山県', '高岡市'], ['富山県', '射水市'],
    ['石川県', '金沢市'], ['石川県', '白山市'], ['石川県', '小松市'],
    ['福井県', '福井市'], ['福井県', '坂井市'], ['福井県', '越前市'],
    ['山梨県', '甲府市'], ['山梨県', '甲斐市'], ['山梨県', '南アルプス市'],
    ['長野県', '長野市'], ['長野県', '松本市'], ['長野県', '上田市'],
    ['岐阜県', '岐阜市'], ['岐阜県', '大垣市'], ['岐阜県', '各務原市'], ['岐阜県', '多治見市'], ['岐阜県', '高山市'],
    ['静岡県', '静岡市'], ['静岡県', '浜松市'], ['静岡県', '沼津市'],
    ['愛知県', '名古屋市'], ['愛知県', '豊田市'], ['愛知県', '岡崎市'], ['愛知県', '一宮市'], ['愛知県', '春日井市'],
    ['三重県', '津市'], ['三重県', '四日市市'], ['三重県', '桑名市'],
    ['滋賀県', '大津市'], ['滋賀県', '草津市'], ['滋賀県', '彦根市'],
    ['京都府', '京都市'], ['京都府', '宇治市'], ['京都府', '舞鶴市'],
    ['大阪府', '大阪市'], ['大阪府', '堺市'], ['大阪府', '東大阪市'], ['大阪府', '豊中市'],
    ['兵庫県', '神戸市'], ['兵庫県', '姫路市'], ['兵庫県', '西宮市'],
    ['奈良県', '奈良市'], ['奈良県', '橿原市'], ['奈良県', '生駒市'],
    ['和歌山県', '和歌山市'], ['和歌山県', '田辺市'], ['和歌山県', '橋本市'],
    ['鳥取県', '鳥取市'], ['鳥取県', '米子市'], ['鳥取県', '倉吉市'],
    ['島根県', '松江市'], ['島根県', '出雲市'], ['島根県', '浜田市'],
    ['岡山県', '岡山市'], ['岡山県', '倉敷市'], ['岡山県', '津山市'],
    ['広島県', '広島市'], ['広島県', '福山市'], ['広島県', '呉市'],
    ['山口県', '山口市'], ['山口県', '下関市'], ['山口県', '宇部市'],
    ['徳島県', '徳島市'], ['徳島県', '鳴門市'], ['徳島県', '阿南市'],
    ['香川県', '高松市'], ['香川県', '丸亀市'], ['香川県', '三豊市'],
    ['愛媛県', '松山市'], ['愛媛県', '今治市'], ['愛媛県', '新居浜市'],
    ['高知県', '高知市'], ['高知県', '南国市'], ['高知県', '四万十市'],
    ['福岡県', '福岡市'], ['福岡県', '北九州市'], ['福岡県', '久留米市'],
    ['佐賀県', '佐賀市'], ['佐賀県', '唐津市'], ['佐賀県', '鳥栖市'],
    ['長崎県', '長崎市'], ['長崎県', '佐世保市'], ['長崎県', '諫早市'],
    ['熊本県', '熊本市'], ['熊本県', '八代市'], ['熊本県', '天草市'],
    ['大分県', '大分市'], ['大分県', '別府市'], ['大分県', '中津市'],
    ['宮崎県', '宮崎市'], ['宮崎県', '都城市'], ['宮崎県', '延岡市'],
    ['鹿児島県', '鹿児島市'], ['鹿児島県', '霧島市'], ['鹿児島県', '鹿屋市'],
    ['沖縄県', '那覇市'], ['沖縄県', '沖縄市'], ['沖縄県', '浦添市'],
  ];

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

  return sheet;
}

/**
 * 役割: 地域マスタをフォーム用に取得する
 * 入力: なし
 * 出力: object - 都道府県ごとの市区町村リスト
 */
function getAreaMaster() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = spreadsheet.getSheetByName(AREA_MASTER_SHEET_NAME);

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

  const values = sheet.getDataRange().getValues();
  const rows = values.slice(1);

  const areaMaster = {};

  rows.forEach((row) => {
    const prefecture = String(row[0] || '').trim();
    const city = String(row[1] || '').trim();

    if (!prefecture || !city) {
      return;
    }

    if (!areaMaster[prefecture]) {
      areaMaster[prefecture] = [];
    }

    if (!areaMaster[prefecture].includes(city)) {
      areaMaster[prefecture].push(city);
    }
  });

  Object.keys(areaMaster).forEach((prefecture) => {
    areaMaster[prefecture].sort();
  });

  return areaMaster;
}

/**
 * 役割: 受付番号を生成する
 * 入力: rowNumber - スプレッドシート上の行番号
 * 出力: string - 受付番号
 */
function createReceptionNumber(rowNumber) {
  const today = Utilities.formatDate(
    new Date(),
    Session.getScriptTimeZone(),
    'yyyyMMdd'
  );

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

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

/**
 * 役割: 氏名が全角カタカナとして有効か判定する
 * 入力: name - 入力された氏名
 * 出力: boolean - 全角カタカナ、スペース、中黒、長音のみならtrue
 */
function isValidKatakanaName(name) {
  const katakanaNamePattern = /^[ァ-ヶー・ \s]+$/;
  return katakanaNamePattern.test(String(name || '').trim());
}

/**
 * 役割: 郵便番号を7桁数字に正規化する
 * 入力: zipcode - 入力された郵便番号
 * 出力: string - ハイフンなし7桁郵便番号
 */
function normalizeZipcode(zipcode) {
  return String(zipcode || '').replace(/[^\d]/g, '');
}

/**
 * 役割: 郵便番号として有効か判定する
 * 入力: zipcode - 入力された郵便番号
 * 出力: boolean - 7桁数字ならtrue
 */
function isValidZipcode(zipcode) {
  const normalizedZipcode = normalizeZipcode(zipcode);
  return /^\d{7}$/.test(normalizedZipcode);
}

/**
 * 役割: 受付フォームから送信されたデータを検証する
 * 入力: formData - フォーム入力データ
 * 出力: object - 検証済みデータ
 */
function validateReceptionData(formData) {
  if (!formData) {
    throw new Error('受付データが送信されていません。');
  }

  const name = String(formData.name || '').trim();
  const visitorType = String(formData.visitorType || '').trim();
  const grade = String(formData.grade || '').trim();
  const className = String(formData.className || '').trim();
  const purpose = String(formData.purpose || '').trim();
  const companionsText = String(formData.companions || '0').trim();
  const zipcode = String(formData.zipcode || '').trim();
  const prefecture = String(formData.prefecture || '').trim();
  const city = String(formData.city || '').trim();
  const addressDetail = String(formData.addressDetail || '').trim();
  const originSchool = String(formData.originSchool || '').trim();
  const memo = String(formData.memo || '').trim();
  const userAgent = String(formData.userAgent || '').trim();

  if (!name) {
    throw new Error('氏名をカタカナで入力してください。');
  }

  if (!isValidKatakanaName(name)) {
    throw new Error('氏名は全角カタカナで入力してください。例:ヤマダ タロウ');
  }

  if (!visitorType) {
    throw new Error('来場区分を選択してください。');
  }

  if (!purpose) {
    throw new Error('来場目的を選択してください。');
  }

  if (!zipcode) {
    throw new Error('郵便番号を入力してください。');
  }

  if (!isValidZipcode(zipcode)) {
    throw new Error('郵便番号は7桁の数字で入力してください。例:1000001');
  }

  if (!prefecture) {
    throw new Error('都道府県を選択してください。');
  }

  if (!city) {
    throw new Error('市区町村を選択してください。');
  }

  const companions = Number(companionsText);

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

  return {
    name,
    visitorType,
    grade,
    className,
    purpose,
    companions,
    zipcode: normalizeZipcode(zipcode),
    prefecture,
    city,
    addressDetail,
    originSchool,
    memo,
    userAgent,
  };
}

/**
 * 役割: 来場者の受付データをスプレッドシートに保存する
 * 入力: formData - フォーム入力データ
 * 出力: object - 受付完了情報
 */
function submitReception(formData) {
  const lock = LockService.getScriptLock();

  try {
    lock.waitLock(10000);

    const validatedData = validateReceptionData(formData);
    const sheet = getReceptionSheet();

    const nextRow = sheet.getLastRow() + 1;
    const receptionNumber = createReceptionNumber(nextRow);
    const now = new Date();

    sheet.appendRow([
      now,
      receptionNumber,
      validatedData.name,
      validatedData.visitorType,
      validatedData.grade,
      validatedData.className,
      validatedData.purpose,
      validatedData.companions,
      validatedData.zipcode,
      validatedData.prefecture,
      validatedData.city,
      validatedData.addressDetail,
      validatedData.originSchool,
      validatedData.memo,
      validatedData.userAgent,
    ]);

    const dashboardData = getDashboardData();
    const address = `${validatedData.prefecture}${validatedData.city}${validatedData.addressDetail}`;

    return {
      success: true,
      receptionNumber,
      totalToday: dashboardData.totalToday,
      total: dashboardData.total,
      targetCount: dashboardData.targetCount,
      achieved: dashboardData.achieved,
      address,
      message: '受付が完了しました。',
    };
  } catch (error) {
    throw new Error(error.message || '受付処理中にエラーが発生しました。');
  } finally {
    lock.releaseLock();
  }
}

/**
 * 役割: 今日の受付件数を数える
 * 入力: なし
 * 出力: number - 今日の受付件数
 */
function countTodayVisitors() {
  const sheet = getReceptionSheet();
  const values = sheet.getDataRange().getValues();

  if (values.length <= 1) {
    return 0;
  }

  const timezone = Session.getScriptTimeZone();
  const todayText = Utilities.formatDate(new Date(), timezone, 'yyyy-MM-dd');

  let count = 0;

  for (let index = 1; index < values.length; index++) {
    const receptionDate = values[index][0];

    if (!(receptionDate instanceof Date)) {
      continue;
    }

    const receptionDateText = Utilities.formatDate(
      receptionDate,
      timezone,
      'yyyy-MM-dd'
    );

    if (receptionDateText === todayText) {
      count++;
    }
  }

  return count;
}

/**
 * 役割: 目標来場者数を取得する
 * 入力: なし
 * 出力: number - 目標来場者数
 */
function getTargetVisitorCount() {
  const savedTargetCount = PropertiesService
    .getScriptProperties()
    .getProperty(TARGET_VISITOR_COUNT_PROPERTY_KEY);

  const targetCount = Number(savedTargetCount || DEFAULT_TARGET_VISITOR_COUNT);

  if (Number.isNaN(targetCount) || targetCount <= 0) {
    return DEFAULT_TARGET_VISITOR_COUNT;
  }

  return Math.floor(targetCount);
}

/**
 * 役割: 集計用オブジェクトに1件追加する
 * 入力: counts - 集計オブジェクト, key - 集計キー
 * 出力: なし
 */
function incrementCount(counts, key) {
  const normalizedKey = String(key || '未設定').trim() || '未設定';
  counts[normalizedKey] = (counts[normalizedKey] || 0) + 1;
}

/**
 * 役割: 管理画面用の集計データを取得する
 * 入力: なし
 * 出力: object - 総受付数、本日受付数、各種集計、最新受付一覧
 */
function getDashboardData() {
  const sheet = getReceptionSheet();
  const values = sheet.getDataRange().getValues();

  const rows = values.slice(1);

  const visitorTypeCounts = {};
  const purposeCounts = {};
  const gradeCounts = {};
  const hourlyCounts = {};
  const prefectureCounts = {};
  const cityCounts = {};
  const originSchoolCounts = {};

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

  rows.forEach((row) => {
    const receptionDate = row[0];

    const visitorType = String(row[3] || '未設定');
    const grade = String(row[4] || '未設定');
    const purpose = String(row[6] || '未設定');
    const prefecture = String(row[9] || '未設定');
    const city = String(row[10] || '未設定');
    const originSchool = String(row[12] || '未入力');

    incrementCount(visitorTypeCounts, visitorType);
    incrementCount(purposeCounts, purpose);
    incrementCount(gradeCounts, grade);
    incrementCount(prefectureCounts, prefecture);
    incrementCount(cityCounts, city);
    incrementCount(originSchoolCounts, originSchool);

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

      hourlyCounts[hourLabel] = (hourlyCounts[hourLabel] || 0) + 1;
    }
  });

  const latestRows = rows
    .slice()
    .reverse()
    .slice(0, 12)
    .map((row) => {
      const prefecture = String(row[9] || '');
      const city = String(row[10] || '');
      const addressDetail = String(row[11] || '');

      return {
        receptionDate: formatDateTime(row[0]),
        receptionNumber: String(row[1] || ''),
        name: String(row[2] || ''),
        visitorType: String(row[3] || ''),
        grade: String(row[4] || ''),
        className: String(row[5] || ''),
        purpose: String(row[6] || ''),
        companions: String(row[7] || '0'),
        zipcode: String(row[8] || ''),
        prefecture,
        city,
        address: `${prefecture}${city}${addressDetail}`,
        originSchool: String(row[12] || ''),
      };
    });

  const total = rows.length;
  const totalToday = countTodayVisitors();
  const targetCount = getTargetVisitorCount();
  const achieved = total >= targetCount;
  const progressRate =
    targetCount === 0
      ? 0
      : Math.min(100, Math.round((total / targetCount) * 100));

  return {
    total,
    totalToday,
    targetCount,
    achieved,
    progressRate,
    visitorTypeCounts,
    purposeCounts,
    gradeCounts,
    hourlyCounts,
    prefectureCounts,
    cityCounts,
    originSchoolCounts,
    latestRows,
    latestReceptionNumber:
      latestRows.length > 0 ? latestRows[0].receptionNumber : '',
    updatedAt: formatDateTime(new Date()),
  };
}

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

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

最初から入っているコード

最初は、次のようなコードが入っている場合があります。

function myFunction() {

}

これは今回使いません。

削除して大丈夫です。


ここに貼るもの

Code.gs には、先生から配布された完成版の Code.gs を貼り付けます。

教材上では、次の部分に貼り付けるコードを用意します。

ここに完成版の Code.gs を貼り付ける

授業では、配布された完成コードをコピーして使います。


貼り付けたら保存する

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

Windows:
Ctrl + S

Mac:
Command + S

または、画面上部の保存アイコンをクリックします。

保存できていないと、あとで実行しても変更が反映されません。


3. index.htmlを作る

次に、画面を作るための index.html を作成します。

Code.gs は最初からありますが、index.html は自分で追加する必要があります。


手順

Apps Scriptの左側にあるファイル一覧を見ます。

その近くにある「+」ボタンを使います。

1. 左側の「+」をクリックする
2. 「HTML」を選ぶ
3. ファイル名に index と入力する
4. Enterを押す

ここで大切なのは、ファイル名です。

入力する名前は、次です。

index

index.html と入力しなくて大丈夫です。

Apps Script上では、index と入力すると、HTMLファイルとして作られます。

コピーするプログラム(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>学校祭デジタル受付</title>
   <!-- 既存の <style>...</style> を、以下の内容に丸ごと置き換えてください。HTML本文と<script>は変更不要です。 -->
<style>
  :root {
    /* DADS Primitive Colors */
    --color-primitive-blue-50: #e8f1fe;
    --color-primitive-blue-100: #d9e6ff;
    --color-primitive-blue-200: #c5d7fb;
    --color-primitive-blue-300: #9db7f9;
    --color-primitive-blue-900: #0017c1;
    --color-primitive-blue-1000: #00118f;
    --color-primitive-blue-1200: #000060;
    --color-primitive-yellow-300: #ffd43d;
    --color-primitive-green-600: #259d63;
    --color-primitive-green-800: #197a4b;
    --color-primitive-red-50: #fdeeee;
    --color-primitive-red-800: #ec0000;
    --color-primitive-red-900: #ce0000;
    --color-primitive-yellow-700: #b78f00;
    --color-primitive-yellow-900: #927200;

    /* DADS Neutral Colors */
    --color-neutral-white: #ffffff;
    --color-neutral-black: #000000;
    --color-neutral-solid-gray-50: #f2f2f2;
    --color-neutral-solid-gray-100: #e6e6e6;
    --color-neutral-solid-gray-200: #cccccc;
    --color-neutral-solid-gray-300: #b3b3b3;
    --color-neutral-solid-gray-420: #949494;
    --color-neutral-solid-gray-536: #767676;
    --color-neutral-solid-gray-600: #666666;
    --color-neutral-solid-gray-700: #4d4d4d;
    --color-neutral-solid-gray-800: #333333;
    --color-neutral-solid-gray-900: #1a1a1a;

    /* DADS Semantic Colors */
    --color-semantic-success-1: var(--color-primitive-green-600);
    --color-semantic-success-2: var(--color-primitive-green-800);
    --color-semantic-error-1: var(--color-primitive-red-800);
    --color-semantic-error-2: var(--color-primitive-red-900);
    --color-semantic-warning-yellow-1: var(--color-primitive-yellow-700);
    --color-semantic-warning-yellow-2: var(--color-primitive-yellow-900);

    /* Role Aliases */
    --bg: var(--color-neutral-white);
    --surface: var(--color-neutral-white);
    --surface-soft: var(--color-neutral-solid-gray-50);
    --text: var(--color-neutral-solid-gray-800);
    --subtext: var(--color-neutral-solid-gray-700);
    --muted: var(--color-neutral-solid-gray-536);
    --line: var(--color-neutral-solid-gray-200);
    --black: var(--color-neutral-black);
    --white: var(--color-neutral-white);

    --green: var(--color-semantic-success-1);
    --green-soft: #e6f5ec;
    --red: var(--color-semantic-error-1);
    --red-soft: var(--color-primitive-red-50);
    --error-bg: var(--color-primitive-red-50);
    --error-text: var(--color-semantic-error-1);
    --success-bg: #e6f5ec;
    --success-text: var(--color-semantic-success-2);

    /* DADS Spacing: 3〜5値のmodular scaleを基本に、既存class互換のため補助値を定義 */
    --space-1: calc(4 / 16 * 1rem);
    --space-2: calc(8 / 16 * 1rem);
    --space-3: calc(12 / 16 * 1rem);
    --space-4: calc(16 / 16 * 1rem);
    --space-5: calc(24 / 16 * 1rem);
    --space-6: calc(24 / 16 * 1rem);
    --space-8: calc(32 / 16 * 1rem);
    --space-10: calc(48 / 16 * 1rem);
    --space-12: calc(64 / 16 * 1rem);

    /* DADS Corner Shapes */
    --radius-sm: calc(8 / 16 * 1rem);
    --radius-md: calc(12 / 16 * 1rem);
    --radius-lg: calc(12 / 16 * 1rem);
    --radius-xl: calc(16 / 16 * 1rem);
    --radius-2xl: calc(16 / 16 * 1rem);
    --radius-3xl: calc(16 / 16 * 1rem);
    --radius-full: 9999px;

    /* DADS Elevation */
    --elevation-1: 0 2px 8px 1px rgba(0, 0, 0, 0.1), 0 1px 5px 0 rgba(0, 0, 0, 0.3);
    --elevation-2: 0 2px 12px 2px rgba(0, 0, 0, 0.1), 0 1px 6px 0 rgba(0, 0, 0, 0.3);
    --elevation-3: 0 4px 16px 3px rgba(0, 0, 0, 0.1), 0 1px 6px 0 rgba(0, 0, 0, 0.3);
    --elevation-4: 0 6px 20px 4px rgba(0, 0, 0, 0.1), 0 2px 6px 0 rgba(0, 0, 0, 0.3);

    --shadow-sm: none;
    --shadow-md: none;
    --shadow-lg: none;

    --font-family-sans: "Noto Sans JP", -apple-system, BlinkMacSystemFont, sans-serif;
    --font-family-mono: "Noto Sans Mono", monospace;
    --header-block-size: 5rem;
  }

  * {
    box-sizing: border-box;
  }

  html {
    min-height: 100%;
    background: var(--bg);
    color: var(--text);
    font-size: 100%;
  }

  body {
    margin: 0;
    min-height: 100vh;
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-family-sans);
    font-size: calc(17 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
    font-feature-settings: normal;
  }

  button,
  input,
  select,
  textarea {
    font: inherit;
  }

  .app {
    width: 100%;
    min-height: 100vh;
    padding: var(--space-6) var(--space-4) var(--space-12);
  }

  .container {
    width: 100%;
    max-width: calc(960 / 16 * 1rem);
    margin: 0 auto;
  }

  .screen-dashboard {
    width: 100%;
    max-width: 100%;
    margin: 0 auto;
  }

  .hero {
    position: relative;
    padding: var(--space-12) 0 var(--space-8);
    border-bottom: 4px solid var(--color-primitive-blue-900);
  }

  .hero::before {
    content: "";
    display: block;
    width: calc(8 / 16 * 1rem);
    height: calc(45 / 16 * 1rem);
    margin-bottom: var(--space-5);
    background: var(--color-primitive-blue-900);
  }

  .hero-label {
    margin: 0 0 var(--space-4);
    color: var(--color-neutral-solid-gray-700);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
    text-transform: none;
  }

  .hero h1 {
    margin: 0;
    color: var(--text);
    font-size: clamp(calc(36 / 16 * 1rem), 5vw, calc(45 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .hero p {
    max-width: calc(640 / 16 * 1rem);
    margin: var(--space-5) 0 0;
    color: var(--subtext);
    font-size: calc(17 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .card {
    padding: 0;
    background: transparent;
  }

  .form-grid {
    display: grid;
    gap: var(--space-6);
    padding-top: var(--space-8);
  }

  .field {
    display: grid;
    gap: var(--space-2);
    padding: 0;
    border-top: 0;
  }

  label {
    color: var(--text);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .required {
    display: inline-flex;
    align-items: center;
    min-height: calc(28 / 16 * 1rem);
    margin-left: var(--space-2);
    padding: 0 var(--space-2);
    border-radius: calc(4 / 16 * 1rem);
    background: var(--color-primitive-red-50);
    color: var(--color-semantic-error-1);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
    opacity: 1;
  }

  .field-help {
    margin: 0;
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  input,
  select,
  textarea {
    width: 100%;
    min-height: calc(48 / 16 * 1rem);
    border: 1px solid var(--color-neutral-solid-gray-600);
    border-radius: calc(8 / 16 * 1rem);
    padding: calc(12 / 16 * 1rem) calc(16 / 16 * 1rem);
    background: var(--surface);
    color: var(--text);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
    outline: none;
    appearance: none;
  }

  select {
    cursor: pointer;
    background-image: linear-gradient(45deg, transparent 50%, var(--color-neutral-solid-gray-800) 50%),
      linear-gradient(135deg, var(--color-neutral-solid-gray-800) 50%, transparent 50%);
    background-position: calc(100% - 20px) 50%, calc(100% - 14px) 50%;
    background-size: 6px 6px, 6px 6px;
    background-repeat: no-repeat;
    padding-right: calc(48 / 16 * 1rem);
  }

  select:disabled,
  input:disabled,
  textarea:disabled {
    border-color: var(--color-neutral-solid-gray-300);
    background: var(--color-neutral-solid-gray-50);
    color: var(--color-neutral-solid-gray-420);
    cursor: not-allowed;
  }

  textarea {
    resize: vertical;
    min-height: calc(112 / 16 * 1rem);
  }

  input::placeholder,
  textarea::placeholder {
    color: var(--color-neutral-solid-gray-536);
    font-weight: 400;
  }

  @media (hover: hover) {
    input:hover,
    select:hover,
    textarea:hover {
      border-color: var(--color-neutral-black);
    }
  }

  input:focus,
  select:focus,
  textarea:focus {
    border-color: var(--color-neutral-black);
    outline: 4px solid var(--color-neutral-black);
    outline-offset: 2px;
    box-shadow: 0 0 0 2px var(--color-primitive-yellow-300);
  }

  input:invalid:not(:placeholder-shown),
  select:invalid,
  textarea:invalid {
    border-color: var(--color-semantic-error-1);
  }

  .row {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-6);
  }

  .button {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-width: calc(136 / 16 * 1rem);
    min-height: calc(56 / 16 * 1rem);
    margin-top: var(--space-8);
    border: 4px double transparent;
    border-radius: calc(8 / 16 * 1rem);
    padding: calc(12 / 16 * 1rem) calc(16 / 16 * 1rem);
    background: var(--color-primitive-blue-900);
    color: var(--color-neutral-white);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
    cursor: pointer;
    text-align: center;
    text-decoration: none;
  }

  @media (hover: hover) {
    .button:hover {
      background: var(--color-primitive-blue-1000);
      opacity: 1;
    }
  }

  .button:active {
    background: var(--color-primitive-blue-1200);
    transform: none;
  }

  .button:focus-visible {
    outline: 4px solid var(--color-neutral-black);
    outline-offset: 2px;
    box-shadow: 0 0 0 2px var(--color-primitive-yellow-300);
  }

  .button:disabled {
    border-color: var(--color-neutral-solid-gray-300);
    background: var(--color-neutral-solid-gray-50);
    color: var(--color-neutral-solid-gray-420);
    cursor: not-allowed;
    opacity: 1;
  }

  .button-secondary {
    border: 1px solid var(--color-primitive-blue-900);
    background: var(--color-neutral-white);
    color: var(--color-primitive-blue-900);
  }

  @media (hover: hover) {
    .button-secondary:hover {
      background: var(--color-primitive-blue-200);
      color: var(--color-primitive-blue-900);
    }
  }

  .notice {
    margin-top: var(--space-5);
    padding: var(--space-5);
    border: 1px solid var(--color-neutral-solid-gray-200);
    border-left: calc(8 / 16 * 1rem) solid var(--color-neutral-solid-gray-536);
    border-radius: calc(12 / 16 * 1rem);
    background: var(--surface-soft);
    color: var(--subtext);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .error {
    display: none;
    margin-top: var(--space-5);
    padding: var(--space-5);
    border: 3px solid var(--color-semantic-error-1);
    border-left-width: calc(8 / 16 * 1rem);
    border-radius: calc(12 / 16 * 1rem);
    background: var(--error-bg);
    color: var(--error-text);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .success-card {
    display: none;
    padding-top: var(--space-12);
    text-align: left;
  }

  .success-icon {
    width: calc(56 / 16 * 1rem);
    height: calc(56 / 16 * 1rem);
    display: grid;
    place-items: center;
    margin: 0 0 var(--space-5);
    border-radius: var(--radius-full);
    background: var(--success-bg);
    color: var(--success-text);
    font-size: calc(28 / 16 * 1rem);
    font-weight: 700;
  }

  .success-card h2 {
    margin: 0;
    color: var(--text);
    font-size: calc(32 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.5;
    letter-spacing: 0.01em;
  }

  .success-card p {
    margin: var(--space-3) 0 0;
    color: var(--subtext);
    font-size: calc(17 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .reception-number {
    margin: var(--space-8) 0;
    padding: var(--space-6);
    border: 1px solid #eeeeee;
    border-radius: calc(12 / 16 * 1rem);
    background: var(--surface);
    box-shadow: none;
  }

  .reception-number span {
    display: block;
    margin-bottom: var(--space-2);
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .reception-number strong {
    display: block;
    color: var(--text);
    font-family: var(--font-family-mono);
    font-size: clamp(calc(28 / 16 * 1rem), 6vw, calc(45 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .success-address {
    color: var(--muted);
    font-size: calc(16 / 16 * 1rem);
  }

  .link-area {
    margin-top: var(--space-6);
    text-align: center;
  }

  .link-area a:any-link {
    color: var(--color-primitive-blue-1000);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
    letter-spacing: 0.02em;
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
    opacity: 1;
  }

  .link-area a:visited {
    color: #6c006c;
  }

  @media (hover: hover) {
    .link-area a:hover {
      text-decoration-thickness: 3px;
      opacity: 1;
    }
  }

  .link-area a:active {
    color: #8b3200;
  }

  .link-area a:focus-visible {
    outline: 4px solid var(--color-neutral-black);
    outline-offset: 2px;
    background: var(--color-primitive-yellow-300);
  }

  .dashboard {
    display: none;
  }

  .dashboard-shell {
    width: 100%;
    min-height: calc(100vh - 48px);
    padding: var(--space-4) 0;
  }

  .dashboard-topbar {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-5);
    margin-bottom: var(--space-8);
    padding-bottom: var(--space-5);
    border-bottom: 4px solid var(--color-primitive-blue-900);
  }

  .dashboard-brand {
    display: grid;
    gap: var(--space-2);
  }

  .dashboard-kicker {
    margin: 0;
    color: var(--color-neutral-solid-gray-700);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
    text-transform: none;
  }

  .dashboard-title {
    margin: 0;
    color: var(--text);
    font-size: clamp(calc(32 / 16 * 1rem), 5vw, calc(45 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .dashboard-meta {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .status-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    min-height: calc(44 / 16 * 1rem);
    padding: 0 var(--space-4);
    border: 1px solid var(--color-semantic-success-2);
    border-radius: var(--radius-full);
    background: var(--green-soft);
    color: var(--color-semantic-success-2);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .status-badge.is-danger {
    border-color: var(--color-semantic-error-1);
    background: var(--red-soft);
    color: var(--color-semantic-error-1);
  }

  .status-dot {
    position: relative;
    width: calc(10 / 16 * 1rem);
    height: calc(10 / 16 * 1rem);
    border-radius: var(--radius-full);
    background: currentcolor;
  }

  .status-dot::after {
    content: "";
    position: absolute;
    inset: calc(-6 / 16 * 1rem);
    border-radius: var(--radius-full);
    background: currentcolor;
    opacity: 0.18;
    animation: pulseDot 1.6s ease-out infinite;
  }

  .updated-text {
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
  }

  .dashboard-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-5);
  }

  .dashboard-card {
    min-height: calc(184 / 16 * 1rem);
    padding: var(--space-6);
    border: 1px solid #eeeeee;
    border-radius: calc(12 / 16 * 1rem);
    background: var(--surface);
    box-shadow: none;
  }

  .dashboard-card-title {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-4);
    margin-bottom: var(--space-5);
  }

  .dashboard-card-title h3 {
    position: relative;
    margin: 0;
    padding-left: var(--space-4);
    color: var(--text);
    font-size: calc(22 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.5;
    letter-spacing: 0.02em;
  }

  .dashboard-card-title h3::before {
    content: "";
    position: absolute;
    left: 0;
    top: 0.25em;
    width: calc(6 / 16 * 1rem);
    height: 1.25em;
    background: var(--color-primitive-blue-900);
  }

  .dashboard-card-title span {
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .hero-count-card {
    min-height: calc(280 / 16 * 1rem);
    display: grid;
    align-content: space-between;
    background: var(--color-primitive-blue-50);
    color: var(--text);
  }

  .hero-count-label {
    margin: 0;
    color: var(--color-neutral-solid-gray-700);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .hero-count-number {
    margin: var(--space-2) 0 0;
    color: var(--color-primitive-blue-1000);
    font-family: var(--font-family-mono);
    font-size: clamp(calc(64 / 16 * 1rem), 13vw, calc(112 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.1;
    letter-spacing: 0;
  }

  .hero-count-number span {
    margin-left: var(--space-2);
    font-family: var(--font-family-sans);
    font-size: clamp(calc(24 / 16 * 1rem), 3vw, calc(36 / 16 * 1rem));
    font-weight: 700;
    letter-spacing: 0.01em;
  }

  .hero-count-sub {
    margin: var(--space-5) 0 0;
    color: var(--subtext);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
  }

  .progress-track {
    width: 100%;
    height: calc(16 / 16 * 1rem);
    overflow: hidden;
    border: 1px solid var(--color-neutral-solid-gray-200);
    border-radius: var(--radius-full);
    background: var(--color-neutral-solid-gray-100);
    margin-top: var(--space-5);
  }

  .progress-bar {
    height: 100%;
    width: 0%;
    border-radius: var(--radius-full);
    background: var(--color-semantic-success-1);
    transition: width 700ms cubic-bezier(0.2, 0.8, 0.2, 1);
  }

  .progress-meta {
    display: flex;
    justify-content: space-between;
    margin-top: var(--space-2);
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
  }

  .summary-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--space-4);
  }

  .summary-item {
    display: grid;
    gap: var(--space-2);
    padding: var(--space-3) 0;
    border-top: 1px solid var(--color-neutral-solid-gray-200);
  }

  .summary-label {
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
  }

  .summary-value {
    color: var(--text);
    font-family: var(--font-family-mono);
    font-size: calc(36 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0.01em;
  }

  .donut-layout {
    display: grid;
    grid-template-columns: 1fr;
    align-items: center;
    gap: var(--space-6);
  }

  .donut {
    --p: 0;
    width: calc(156 / 16 * 1rem);
    height: calc(156 / 16 * 1rem);
    border-radius: 50%;
    background: conic-gradient(var(--color-semantic-success-1) calc(var(--p) * 1%), var(--color-neutral-solid-gray-100) 0);
    display: grid;
    place-items: center;
  }

  .donut-inner {
    width: calc(104 / 16 * 1rem);
    height: calc(104 / 16 * 1rem);
    border-radius: 50%;
    background: var(--surface);
    display: grid;
    place-items: center;
    text-align: center;
  }

  .donut-inner strong {
    display: block;
    color: var(--text);
    font-family: var(--font-family-mono);
    font-size: calc(28 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .donut-inner span {
    display: block;
    margin-top: var(--space-1);
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  .donut-legend {
    display: grid;
    gap: var(--space-3);
  }

  .legend-row {
    display: grid;
    grid-template-columns: calc(12 / 16 * 1rem) 1fr auto;
    align-items: center;
    gap: var(--space-3);
    color: var(--subtext);
    font-size: calc(15 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
  }

  .legend-dot {
    width: calc(12 / 16 * 1rem);
    height: calc(12 / 16 * 1rem);
    border-radius: 50%;
    background: var(--color-primitive-blue-900);
  }

  .legend-dot.is-muted {
    background: var(--color-neutral-solid-gray-200);
  }

  .bar-chart {
    display: grid;
    gap: var(--space-4);
  }

  .bar-row {
    display: grid;
    grid-template-columns: minmax(calc(88 / 16 * 1rem), 0.34fr) 1fr calc(48 / 16 * 1rem);
    align-items: center;
    gap: var(--space-3);
  }

  .bar-label {
    color: var(--subtext);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    line-height: 1.7;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  .bar-track {
    height: calc(16 / 16 * 1rem);
    overflow: hidden;
    border: 1px solid var(--color-neutral-solid-gray-200);
    border-radius: var(--radius-full);
    background: var(--color-neutral-solid-gray-100);
  }

  .bar-fill {
    height: 100%;
    width: 0%;
    border-radius: var(--radius-full);
    background: var(--color-primitive-blue-900);
    transition: width 650ms cubic-bezier(0.2, 0.8, 0.2, 1);
  }

  .bar-value {
    color: var(--text);
    font-family: var(--font-family-mono);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
    text-align: right;
  }

  .line-chart {
    width: 100%;
    height: calc(184 / 16 * 1rem);
  }

  .line-chart svg {
    width: 100%;
    height: 100%;
    overflow: visible;
  }

  .line-axis {
    stroke: var(--color-neutral-solid-gray-200);
    stroke-width: 1;
  }

  .line-path {
    fill: none;
    stroke: var(--color-primitive-blue-900);
    stroke-width: 3;
    stroke-linecap: round;
    stroke-linejoin: round;
  }

  .line-area {
    fill: var(--color-primitive-blue-50);
  }

  .line-point {
    fill: var(--color-primitive-blue-900);
  }

  .line-labels {
    display: flex;
    justify-content: space-between;
    margin-top: var(--space-3);
    color: var(--muted);
    font-size: calc(14 / 16 * 1rem);
    font-weight: 700;
  }

  .pie-layout {
    display: grid;
    grid-template-columns: 1fr;
    align-items: center;
    gap: var(--space-6);
  }

  .pie {
    width: calc(140 / 16 * 1rem);
    height: calc(140 / 16 * 1rem);
    border-radius: 50%;
    background: var(--color-neutral-solid-gray-100);
  }

  .latest-table-card {
    min-height: calc(280 / 16 * 1rem);
  }

  .table-wrap {
    overflow-x: auto;
    border: 1px solid var(--color-neutral-solid-gray-200);
    border-radius: calc(8 / 16 * 1rem);
  }

  table {
    width: 100%;
    min-width: calc(980 / 16 * 1rem);
    border-collapse: collapse;
    background: var(--surface);
  }

  th,
  td {
    padding: calc(12 / 16 * 1rem) calc(8 / 16 * 1rem);
    border-bottom: 1px solid var(--color-neutral-solid-gray-200);
    color: var(--text);
    font-size: calc(14 / 16 * 1rem);
    text-align: left;
    white-space: nowrap;
    line-height: 1.7;
    letter-spacing: 0.02em;
  }

  th {
    background: var(--color-neutral-solid-gray-50);
    color: var(--color-neutral-solid-gray-700);
    font-weight: 700;
  }

  td {
    color: var(--subtext);
    font-weight: 400;
  }

  .dashboard-empty {
    color: var(--muted);
    font-size: calc(16 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.7;
  }

  .arrival-toast {
    position: fixed;
    left: 50%;
    top: 50%;
    z-index: 50;
    display: none;
    transform: translate(-50%, -50%) scale(0.94);
    width: min(calc(680 / 16 * 1rem), calc(100vw - 36px));
    padding: var(--space-10) var(--space-8);
    border: 3px solid var(--color-primitive-blue-900);
    border-radius: calc(12 / 16 * 1rem);
    background: var(--color-neutral-white);
    color: var(--text);
    text-align: center;
    box-shadow: var(--shadow-lg);
  }

  .arrival-toast.show {
    display: block;
    animation: arrivalPop 2.5s ease forwards;
  }

  .arrival-toast h2 {
    margin: 0;
    color: var(--color-primitive-blue-1000);
    font-size: clamp(calc(36 / 16 * 1rem), 8vw, calc(57 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .arrival-toast p {
    margin: var(--space-4) 0 0;
    color: var(--subtext);
    font-size: calc(18 / 16 * 1rem);
    font-weight: 400;
    line-height: 1.6;
  }

  .achievement-overlay {
    position: fixed;
    inset: 0;
    z-index: 60;
    display: none;
    place-items: center;
    background: rgba(255, 255, 255, 0.94);
  }

  .achievement-overlay.show {
    display: grid;
    animation: fadeIn 250ms ease forwards;
  }

  .achievement-card {
    width: min(calc(820 / 16 * 1rem), calc(100vw - 40px));
    padding: var(--space-12) var(--space-8);
    border: 4px solid var(--color-primitive-blue-900);
    border-radius: calc(12 / 16 * 1rem);
    background: var(--color-primitive-blue-50);
    color: var(--text);
    text-align: center;
  }

  .achievement-card h2 {
    margin: 0;
    color: var(--color-primitive-blue-1000);
    font-size: clamp(calc(45 / 16 * 1rem), 10vw, calc(64 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.4;
    letter-spacing: 0;
  }

  .achievement-card p {
    margin: var(--space-6) 0 0;
    color: var(--subtext);
    font-size: clamp(calc(20 / 16 * 1rem), 4vw, calc(28 / 16 * 1rem));
    font-weight: 700;
    line-height: 1.5;
  }

  .confetti {
    position: fixed;
    top: -20px;
    width: calc(8 / 16 * 1rem);
    height: calc(16 / 16 * 1rem);
    z-index: 70;
    opacity: 0.9;
    background: var(--color-primitive-blue-900);
    animation: confettiFall 2.8s linear forwards;
  }

  @keyframes pulseDot {
    0% { transform: scale(0.6); opacity: 0.36; }
    70% { transform: scale(1.9); opacity: 0; }
    100% { transform: scale(1.9); opacity: 0; }
  }

  @keyframes arrivalPop {
    0% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
    14% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    82% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
    100% { opacity: 0; transform: translate(-50%, -50%) scale(0.96); }
  }

  @keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
  }

  @keyframes confettiFall {
    0% { transform: translateY(0) rotate(0deg); }
    100% { transform: translateY(110vh) rotate(720deg); }
  }

  /* DADS breakpoint: 768px only */
  @media (min-width: 48rem) {
    .app {
      padding-top: var(--space-12);
    }

    .container {
      max-width: calc(1080 / 16 * 1rem);
    }

    .row {
      grid-template-columns: 1fr 1fr;
      gap: var(--space-8);
    }

    .dashboard-topbar {
      grid-template-columns: 1fr auto;
      align-items: start;
    }

    .dashboard-meta {
      justify-content: flex-end;
      text-align: right;
    }

    .donut-layout {
      grid-template-columns: calc(156 / 16 * 1rem) 1fr;
    }

    .pie-layout {
      grid-template-columns: calc(140 / 16 * 1rem) 1fr;
    }

    .screen-dashboard .dashboard-shell {
      padding: var(--space-5);
    }

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

    .screen-dashboard .hero-count-card {
      grid-column: span 1;
    }

    .screen-dashboard .summary-card {
      grid-column: span 1;
    }

    .screen-dashboard .donut-card {
      grid-column: span 1;
    }

    .screen-dashboard .latest-table-card {
      grid-column: span 3;
    }
  }

  @media (forced-colors: active) {
    .hero::before,
    .dashboard-card-title h3::before,
    .progress-bar,
    .bar-fill,
    .confetti {
      background: CanvasText;
    }

    .button:disabled {
      border-color: GrayText;
      color: GrayText;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      scroll-behavior: auto !important;
      transition-duration: 0.001ms !important;
    }
  }
</style>

  </head>

  <body>
    <div class="app">
      <div class="container" id="mainContainer">
        <div id="formHero" class="hero">
          <p class="hero-label">School Festival Reception</p>
          <h1>学校祭デジタル受付</h1>
          <p>
            ご来場ありがとうございます。必要事項を入力して受付を完了してください。
            氏名はカタカナで入力してください。
          </p>
        </div>

        <div id="formView" class="card">
          <form id="receptionForm" class="form-grid">
            <div class="field">
              <label for="name">氏名カナ<span class="required">必須</span></label>
              <input
                id="name"
                name="name"
                type="text"
                placeholder="ヤマダ タロウ"
                pattern="^[ァ-ヶー・ \s]+$"
                title="氏名は全角カタカナで入力してください。例:ヤマダ タロウ"
                inputmode="katakana"
                autocomplete="name"
                required
              />
              <p class="field-help">全角カタカナで入力してください。</p>
            </div>

            <div class="field">
              <label for="visitorType">来場区分<span class="required">必須</span></label>
              <select id="visitorType" name="visitorType" required>
                <option value="">選択してください</option>
                <option value="保護者">保護者</option>
                <option value="在校生">在校生</option>
                <option value="卒業生">卒業生</option>
                <option value="地域の方">地域の方</option>
                <option value="学校関係者">学校関係者</option>
                <option value="その他">その他</option>
              </select>
            </div>

            <div class="row">
              <div class="field">
                <label for="grade">学年</label>
                <select id="grade" name="grade">
                  <option value="">該当なし</option>
                  <option value="1年">1年</option>
                  <option value="2年">2年</option>
                  <option value="3年">3年</option>
                  <option value="4年">4年</option>
                  <option value="5年">5年</option>
                  <option value="6年">6年</option>
                </select>
              </div>

              <div class="field">
                <label for="className">クラス</label>
                <input
                  id="className"
                  name="className"
                  type="text"
                  placeholder="A組 / 1組"
                />
              </div>
            </div>

            <div class="field">
              <label for="purpose">来場目的<span class="required">必須</span></label>
              <select id="purpose" name="purpose" required>
                <option value="">選択してください</option>
                <option value="文化祭見学">文化祭見学</option>
                <option value="発表・展示の見学">発表・展示の見学</option>
                <option value="模擬店利用">模擬店利用</option>
                <option value="学校説明会">学校説明会</option>
                <option value="生徒の応援">生徒の応援</option>
                <option value="その他">その他</option>
              </select>
            </div>

            <div class="field">
              <label for="companions">同伴人数</label>
              <input
                id="companions"
                name="companions"
                type="number"
                min="0"
                value="0"
              />
            </div>

            <div class="field">
              <label for="zipcode">郵便番号<span class="required">必須</span></label>
              <input
                id="zipcode"
                name="zipcode"
                type="text"
                placeholder="1000001"
                inputmode="numeric"
                autocomplete="postal-code"
                maxlength="8"
                required
              />
              <p class="field-help">ハイフン付きでも入力できます。</p>
            </div>

            <div class="field">
              <label for="prefecture">都道府県<span class="required">必須</span></label>
              <select id="prefecture" name="prefecture" required>
                <option value="">選択してください</option>
              </select>
            </div>

            <div class="field">
              <label for="city">市区町村<span class="required">必須</span></label>
              <select id="city" name="city" required disabled>
                <option value="">先に都道府県を選択してください</option>
              </select>
            </div>

            <div class="field">
              <label for="addressDetail">番地・建物名</label>
              <input
                id="addressDetail"
                name="addressDetail"
                type="text"
                placeholder="千代田1-1 / 〇〇マンション101"
                autocomplete="street-address"
              />
              <p class="field-help">任意入力です。</p>
            </div>

            <div class="field">
              <label for="originSchool">出身学校</label>
              <input
                id="originSchool"
                name="originSchool"
                type="text"
                placeholder="〇〇小学校 / 〇〇中学校 / 〇〇高等学校"
              />
              <p class="field-help">任意入力です。</p>
            </div>

            <div class="field">
              <label for="memo">備考</label>
              <textarea
                id="memo"
                name="memo"
                placeholder="必要な連絡事項があれば入力してください。"
              ></textarea>
            </div>

            <button id="submitButton" class="button" type="submit">
              受付する
            </button>

            <div class="notice">
              入力いただいた情報は、学校祭当日の受付管理・安全管理・来場傾向の確認の目的で使用します。
            </div>

            <div id="errorMessage" class="error"></div>
          </form>
        </div>

        <div id="successView" class="card success-card">
          <div class="success-icon"></div>
          <h2>受付が完了しました</h2>
          <p>受付で下記の番号をお見せください。</p>

          <div class="reception-number">
            <span>受付番号</span>
            <strong id="receptionNumber">---</strong>
          </div>

          <p id="todayCountText"></p>
          <p id="successAddress" class="success-address"></p>

          <button id="resetButton" class="button button-secondary" type="button">
            続けて受付する
          </button>
        </div>

        <div id="dashboardView" class="dashboard">
          <div class="dashboard-shell">
            <div class="dashboard-topbar">
              <div class="dashboard-brand">
                <p class="dashboard-kicker">学校祭 受付本部</p>
                <h2 class="dashboard-title">来場状況ダッシュボード</h2>
              </div>

              <div class="dashboard-meta">
                <div class="status-badge" id="liveStatusBadge">
                  <span class="status-dot"></span>
                  <span id="liveStatusText">リアルタイム更新中</span>
                </div>
                <div id="updatedAt" class="updated-text">最終更新:--</div>
              </div>
            </div>

            <div class="dashboard-grid">
              <section class="dashboard-card hero-count-card">
                <div>
                  <p class="hero-count-label">本日の来場者数</p>
                  <p class="hero-count-number">
                    <strong id="todayHeroCount">0</strong><span></span>
                  </p>
                  <p class="hero-count-sub">
                    目標 <span id="targetCountText">100</span>人 /
                    残り <span id="remainingCountText">100</span></p>
                </div>

                <div>
                  <div class="progress-track">
                    <div id="progressBar" class="progress-bar"></div>
                  </div>
                  <div class="progress-meta">
                    <span id="progressRateText">0%</span>
                    <span>目標達成率</span>
                  </div>
                </div>
              </section>

              <section class="dashboard-card summary-card">
                <div class="dashboard-card-title">
                  <h3>全体サマリー</h3>
                  <span>集計</span>
                </div>

                <div class="summary-grid">
                  <div class="summary-item">
                    <span class="summary-label">総受付数</span>
                    <strong id="totalCount" class="summary-value">0</strong>
                  </div>

                  <div class="summary-item">
                    <span class="summary-label">本日</span>
                    <strong id="todayCount" class="summary-value">0</strong>
                  </div>

                  <div class="summary-item">
                    <span class="summary-label">保護者</span>
                    <strong id="parentCount" class="summary-value">0</strong>
                  </div>

                  <div class="summary-item">
                    <span class="summary-label">在校生</span>
                    <strong id="studentCount" class="summary-value">0</strong>
                  </div>
                </div>
              </section>

              <section class="dashboard-card donut-card">
                <div class="dashboard-card-title">
                  <h3>目標達成率</h3>
                  <span>円グラフ</span>
                </div>

                <div class="donut-layout">
                  <div id="goalDonut" class="donut" style="--p: 0">
                    <div class="donut-inner">
                      <div>
                        <strong id="goalDonutText">0%</strong>
                        <span>達成率</span>
                      </div>
                    </div>
                  </div>

                  <div class="donut-legend">
                    <div class="legend-row">
                      <span class="legend-dot" style="background: var(--green);"></span>
                      <span>現在</span>
                      <strong id="completedLegend">0</strong>
                    </div>
                    <div class="legend-row">
                      <span class="legend-dot is-muted"></span>
                      <span>残り</span>
                      <strong id="remainingLegend">100</strong>
                    </div>
                  </div>
                </div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>都道府県別</h3>
                  <span>棒グラフ</span>
                </div>
                <div id="prefectureChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>市区町村別</h3>
                  <span>棒グラフ</span>
                </div>
                <div id="cityChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>来場区分</h3>
                  <span>円グラフ</span>
                </div>
                <div class="pie-layout">
                  <div id="visitorTypePie" class="pie"></div>
                  <div id="visitorTypeChart" class="donut-legend"></div>
                </div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>時間帯別推移</h3>
                  <span>折れ線</span>
                </div>
                <div id="hourlyLineChart" class="line-chart"></div>
                <div id="hourlyLineLabels" class="line-labels"></div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>学年別</h3>
                  <span>棒グラフ</span>
                </div>
                <div id="gradeChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card">
                <div class="dashboard-card-title">
                  <h3>来場目的</h3>
                  <span>棒グラフ</span>
                </div>
                <div id="purposeChart" class="bar-chart"></div>
              </section>

              <section class="dashboard-card latest-table-card">
                <div class="dashboard-card-title">
                  <h3>最新受付</h3>
                  <span>自動更新</span>
                </div>

                <div class="table-wrap">
                  <table>
                    <thead>
                      <tr>
                        <th>受付時刻</th>
                        <th>受付番号</th>
                        <th>氏名</th>
                        <th>区分</th>
                        <th>学年</th>
                        <th>クラス</th>
                        <th>目的</th>
                        <th>郵便番号</th>
                        <th>都道府県</th>
                        <th>市区町村</th>
                        <th>住所</th>
                        <th>出身学校</th>
                      </tr>
                    </thead>
                    <tbody id="latestTableBody"></tbody>
                  </table>
                </div>
              </section>
            </div>

            <div class="link-area">
              <a href="?page=form">受付フォームに戻る</a>
            </div>
          </div>
        </div>

        <div class="link-area" id="adminLink">
          <a href="?page=admin">管理画面を見る</a>
        </div>
      </div>
    </div>

    <div id="arrivalToast" class="arrival-toast">
      <h2>新しい来場者</h2>
      <p id="arrivalToastText">新しい受付が登録されました。</p>
    </div>

    <div id="achievementOverlay" class="achievement-overlay">
      <div class="achievement-card">
        <h2>目標達成</h2>
        <p id="achievementText">来場者100人を達成しました。</p>
      </div>
    </div>

    <script>
      const page = '<?= page ?>';

      const formHero = document.getElementById('formHero');
      const formView = document.getElementById('formView');
      const successView = document.getElementById('successView');
      const dashboardView = document.getElementById('dashboardView');
      const adminLink = document.getElementById('adminLink');
      const receptionForm = document.getElementById('receptionForm');
      const submitButton = document.getElementById('submitButton');
      const resetButton = document.getElementById('resetButton');
      const errorMessage = document.getElementById('errorMessage');
      const arrivalToast = document.getElementById('arrivalToast');
      const achievementOverlay = document.getElementById('achievementOverlay');

      let adminPasscode = '';
      let previousTotalCount = null;
      let hasShownAchievement = false;
      let areaMaster = {};

      function initializePage() {
        if (page === 'admin') {
          formHero.style.display = 'none';
          formView.style.display = 'none';
          successView.style.display = 'none';
          dashboardView.style.display = 'block';
          adminLink.style.display = 'none';

          document.getElementById('mainContainer').classList.add('screen-dashboard');

          loadDashboard(true);

          setInterval(() => {
            loadDashboard(false);
          }, 10000);

          return;
        }

        formHero.style.display = 'block';
        formView.style.display = 'block';
        successView.style.display = 'none';
        dashboardView.style.display = 'none';
        adminLink.style.display = 'block';

        loadAreaMaster();
      }

      function setLiveStatus(isHealthy) {
        const badge = document.getElementById('liveStatusBadge');
        const text = document.getElementById('liveStatusText');

        if (!badge || !text) {
          return;
        }

        if (isHealthy) {
          badge.classList.remove('is-danger');
          text.textContent = 'リアルタイム更新中';
          return;
        }

        badge.classList.add('is-danger');
        text.textContent = '更新エラー';
      }

      function loadAreaMaster() {
        google.script.run
          .withSuccessHandler((master) => {
            areaMaster = master || {};
            renderPrefectureOptions(areaMaster);
          })
          .withFailureHandler((error) => {
            showError(error.message || '地域マスタの読み込みに失敗しました。');
          })
          .getAreaMaster();
      }

      function renderPrefectureOptions(master) {
        const prefectureSelect = document.getElementById('prefecture');
        const prefectures = Object.keys(master);

        prefectureSelect.innerHTML = '<option value="">選択してください</option>';

        prefectures.forEach((prefecture) => {
          const option = document.createElement('option');
          option.value = prefecture;
          option.textContent = prefecture;
          prefectureSelect.appendChild(option);
        });
      }

      function renderCityOptions(prefecture) {
        const citySelect = document.getElementById('city');
        const cities = areaMaster[prefecture] || [];

        citySelect.innerHTML = '';

        if (!prefecture || cities.length === 0) {
          citySelect.disabled = true;
          citySelect.innerHTML = '<option value="">先に都道府県を選択してください</option>';
          return;
        }

        citySelect.disabled = false;
        citySelect.innerHTML = '<option value="">選択してください</option>';

        cities.forEach((city) => {
          const option = document.createElement('option');
          option.value = city;
          option.textContent = city;
          citySelect.appendChild(option);
        });
      }

      function getFormData() {
        return {
          name: document.getElementById('name').value,
          visitorType: document.getElementById('visitorType').value,
          grade: document.getElementById('grade').value,
          className: document.getElementById('className').value,
          purpose: document.getElementById('purpose').value,
          companions: document.getElementById('companions').value,
          zipcode: document.getElementById('zipcode').value,
          prefecture: document.getElementById('prefecture').value,
          city: document.getElementById('city').value,
          addressDetail: document.getElementById('addressDetail').value,
          originSchool: document.getElementById('originSchool').value,
          memo: document.getElementById('memo').value,
          userAgent: window.navigator.userAgent,
        };
      }

      function isValidKatakanaName(name) {
        const katakanaNamePattern = /^[ァ-ヶー・ \s]+$/;
        return katakanaNamePattern.test(String(name || '').trim());
      }

      function normalizeZipcodeClient(zipcode) {
        return String(zipcode || '').replace(/[^\d]/g, '');
      }

      function isValidZipcode(zipcode) {
        const normalizedZipcode = normalizeZipcodeClient(zipcode);
        return /^\d{7}$/.test(normalizedZipcode);
      }

      function showError(message) {
        errorMessage.textContent = message;
        errorMessage.style.display = 'block';
      }

      function hideError() {
        errorMessage.textContent = '';
        errorMessage.style.display = 'none';
      }

      function showSuccess(result) {
        document.getElementById('receptionNumber').textContent =
          result.receptionNumber;

        document.getElementById('todayCountText').textContent =
          `本日の受付数:${result.totalToday}件 / 現在入場者:${result.total}人`;

        document.getElementById('successAddress').textContent =
          result.address ? `登録住所:${result.address}` : '';

        formView.style.display = 'none';
        successView.style.display = 'block';
        adminLink.style.display = 'none';
      }

      function handleSubmit(event) {
        event.preventDefault();
        hideError();

        const formData = getFormData();

        if (!isValidKatakanaName(formData.name)) {
          showError('氏名は全角カタカナで入力してください。例:ヤマダ タロウ');
          return;
        }

        if (!isValidZipcode(formData.zipcode)) {
          showError('郵便番号は7桁の数字で入力してください。例:1000001');
          return;
        }

        if (!String(formData.prefecture || '').trim()) {
          showError('都道府県を選択してください。');
          return;
        }

        if (!String(formData.city || '').trim()) {
          showError('市区町村を選択してください。');
          return;
        }

        submitButton.disabled = true;
        submitButton.textContent = '受付中...';

        google.script.run
          .withSuccessHandler((result) => {
            submitButton.disabled = false;
            submitButton.textContent = '受付する';
            showSuccess(result);
          })
          .withFailureHandler((error) => {
            submitButton.disabled = false;
            submitButton.textContent = '受付する';
            showError(error.message || '受付に失敗しました。');
          })
          .submitReception(formData);
      }

      function loadDashboard(shouldAskPasscode) {
        if (shouldAskPasscode || !adminPasscode) {
          const inputPasscode = window.prompt('管理者パスコードを入力してください。');

          if (!inputPasscode) {
            alert('管理者パスコードが入力されていません。');
            location.href = '?page=form';
            return;
          }

          adminPasscode = inputPasscode;
        }

        google.script.run
          .withSuccessHandler((data) => {
            setLiveStatus(true);
            renderDashboard(data);
          })
          .withFailureHandler((error) => {
            setLiveStatus(false);
            alert(error.message || '管理画面の読み込みに失敗しました。');
            adminPasscode = '';
            location.href = '?page=form';
          })
          .getDashboardDataSecure(adminPasscode);
      }

      function renderDashboard(data) {
        const currentTotal = Number(data.total || 0);
        const totalToday = Number(data.totalToday || 0);
        const targetCount = Number(data.targetCount || 100);
        const remainingCount = Math.max(0, targetCount - currentTotal);
        const progressRate = Number(data.progressRate || 0);

        document.getElementById('todayHeroCount').textContent = totalToday;
        document.getElementById('totalCount').textContent = currentTotal;
        document.getElementById('todayCount').textContent = totalToday;
        document.getElementById('targetCountText').textContent = targetCount;
        document.getElementById('remainingCountText').textContent = remainingCount;
        document.getElementById('progressRateText').textContent = `${progressRate}%`;
        document.getElementById('progressBar').style.width = `${progressRate}%`;
        document.getElementById('updatedAt').textContent =
          `最終更新:${data.updatedAt || '-'}`;

        document.getElementById('goalDonut').style.setProperty('--p', progressRate);
        document.getElementById('goalDonutText').textContent = `${progressRate}%`;
        document.getElementById('completedLegend').textContent = currentTotal;
        document.getElementById('remainingLegend').textContent = remainingCount;

        const visitorTypeCounts = data.visitorTypeCounts || {};

        document.getElementById('parentCount').textContent =
          visitorTypeCounts['保護者'] || 0;

        document.getElementById('studentCount').textContent =
          visitorTypeCounts['在校生'] || 0;

        renderBarChart('prefectureChart', data.prefectureCounts || {}, 6);
        renderBarChart('cityChart', data.cityCounts || {}, 6);
        renderVisitorTypePie(data.visitorTypeCounts || {});
        renderLineChart('hourlyLineChart', 'hourlyLineLabels', data.hourlyCounts || {});
        renderBarChart('gradeChart', data.gradeCounts || {}, 6);
        renderBarChart('purposeChart', data.purposeCounts || {}, 6);
        renderLatestRows(data.latestRows || []);

        if (previousTotalCount !== null && currentTotal > previousTotalCount) {
          showArrivalAnimation(data.latestRows && data.latestRows[0]);
        }

        if (
          previousTotalCount !== null &&
          previousTotalCount < targetCount &&
          currentTotal >= targetCount &&
          !hasShownAchievement
        ) {
          showAchievementAnimation(targetCount);
          hasShownAchievement = true;
        }

        if (currentTotal < targetCount) {
          hasShownAchievement = false;
        }

        previousTotalCount = currentTotal;
      }

      function renderBarChart(elementId, counts, limit) {
        const element = document.getElementById(elementId);
        const entries = Object.entries(counts || {})
          .filter(([, count]) => Number(count) > 0)
          .sort((firstEntry, secondEntry) => Number(secondEntry[1]) - Number(firstEntry[1]))
          .slice(0, limit || 8);

        if (entries.length === 0) {
          element.innerHTML = '<div class="dashboard-empty">まだデータがありません。</div>';
          return;
        }

        const maxValue = Math.max(...entries.map(([, count]) => Number(count)));

        element.innerHTML = entries
          .map(([label, count]) => {
            const numericCount = Number(count);
            const width = maxValue === 0
              ? 0
              : Math.round((numericCount / maxValue) * 100);

            return `
              <div class="bar-row">
                <div class="bar-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>
                <div class="bar-track">
                  <div class="bar-fill" style="width: ${width}%"></div>
                </div>
                <div class="bar-value">${numericCount}</div>
              </div>
            `;
          })
          .join('');
      }

      function renderVisitorTypePie(counts) {
        const legend = document.getElementById('visitorTypeChart');
        const pie = document.getElementById('visitorTypePie');

        const entries = Object.entries(counts || {})
          .filter(([, count]) => Number(count) > 0)
          .sort((a, b) => Number(b[1]) - Number(a[1]));

        if (entries.length === 0) {
          legend.innerHTML = '<div class="dashboard-empty">まだデータがありません。</div>';
          pie.style.background = 'rgba(17, 24, 39, 0.08)';
          return;
        }

        const total = entries.reduce((sum, [, count]) => sum + Number(count), 0);
        let currentDegree = 0;
        const colors = [
          'rgba(17, 24, 39, 1)',
          'rgba(17, 24, 39, 0.68)',
          'rgba(17, 24, 39, 0.44)',
          'rgba(17, 24, 39, 0.24)',
          'rgba(17, 24, 39, 0.12)',
          'rgba(17, 24, 39, 0.08)'
        ];

        const segments = entries.map(([, count], index) => {
          const value = Number(count);
          const degree = total === 0 ? 0 : (value / total) * 360;
          const start = currentDegree;
          const end = currentDegree + degree;
          currentDegree = end;
          return `${colors[index % colors.length]} ${start}deg ${end}deg`;
        });

        pie.style.background = `conic-gradient(${segments.join(', ')})`;

        legend.innerHTML = entries
          .map(([label, count], index) => {
            return `
              <div class="legend-row">
                <span class="legend-dot" style="background:${colors[index % colors.length]}"></span>
                <span>${escapeHtml(label)}</span>
                <strong>${Number(count)}</strong>
              </div>
            `;
          })
          .join('');
      }

      function renderLineChart(chartId, labelsId, counts) {
        const chart = document.getElementById(chartId);
        const labels = document.getElementById(labelsId);

        const entries = Object.entries(counts || {})
          .filter(([label]) => {
            const hour = Number(String(label).slice(0, 2));
            return hour >= 8 && hour <= 20;
          });

        if (entries.length === 0) {
          chart.innerHTML = '<div class="dashboard-empty">まだデータがありません。</div>';
          labels.innerHTML = '';
          return;
        }

        const width = 520;
        const height = 160;
        const padding = 12;
        const maxValue = Math.max(1, ...entries.map(([, value]) => Number(value)));
        const stepX = entries.length <= 1
          ? 0
          : (width - padding * 2) / (entries.length - 1);

        const points = entries.map(([label, value], index) => {
          const x = padding + stepX * index;
          const y = height - padding - (Number(value) / maxValue) * (height - padding * 2);
          return { label, value: Number(value), x, y };
        });

        const linePath = points
          .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`)
          .join(' ');

        const areaPath = `${linePath} L ${points[points.length - 1].x} ${height - padding} L ${points[0].x} ${height - padding} Z`;

        chart.innerHTML = `
          <svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-label="時間帯別折れ線グラフ">
            <line class="line-axis" x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}"></line>
            <line class="line-axis" x1="${padding}" y1="${padding}" x2="${padding}" y2="${height - padding}"></line>
            <path class="line-area" d="${areaPath}"></path>
            <path class="line-path" d="${linePath}"></path>
            ${points
              .map((point) => `<circle class="line-point" cx="${point.x}" cy="${point.y}" r="3"></circle>`)
              .join('')}
          </svg>
        `;

        const first = entries[0] ? entries[0][0] : '';
        const middle = entries[Math.floor(entries.length / 2)] ? entries[Math.floor(entries.length / 2)][0] : '';
        const last = entries[entries.length - 1] ? entries[entries.length - 1][0] : '';

        labels.innerHTML = `
          <span>${escapeHtml(first)}</span>
          <span>${escapeHtml(middle)}</span>
          <span>${escapeHtml(last)}</span>
        `;
      }

      function renderLatestRows(rows) {
        const tbody = document.getElementById('latestTableBody');

        if (!rows || rows.length === 0) {
          tbody.innerHTML = `
            <tr>
              <td colspan="12">まだ受付データがありません。</td>
            </tr>
          `;
          return;
        }

        tbody.innerHTML = rows
          .map((row) => {
            return `
              <tr>
                <td>${escapeHtml(row.receptionDate)}</td>
                <td>${escapeHtml(row.receptionNumber)}</td>
                <td>${escapeHtml(row.name)}</td>
                <td>${escapeHtml(row.visitorType)}</td>
                <td>${escapeHtml(row.grade)}</td>
                <td>${escapeHtml(row.className)}</td>
                <td>${escapeHtml(row.purpose)}</td>
                <td>${escapeHtml(row.zipcode)}</td>
                <td>${escapeHtml(row.prefecture)}</td>
                <td>${escapeHtml(row.city)}</td>
                <td>${escapeHtml(row.address)}</td>
                <td>${escapeHtml(row.originSchool)}</td>
              </tr>
            `;
          })
          .join('');
      }

      function showArrivalAnimation(latestRow) {
        const label = latestRow && latestRow.visitorType
          ? `${latestRow.visitorType}の方が来場されました`
          : '新しい受付が登録されました';

        document.getElementById('arrivalToastText').textContent = label;

        arrivalToast.classList.remove('show');
        void arrivalToast.offsetWidth;
        arrivalToast.classList.add('show');

        setTimeout(() => {
          arrivalToast.classList.remove('show');
        }, 2600);
      }

      function showAchievementAnimation(targetCount) {
        document.getElementById('achievementText').textContent =
          `来場者${targetCount}人を達成しました。`;

        achievementOverlay.classList.add('show');
        createConfetti();

        setTimeout(() => {
          achievementOverlay.classList.remove('show');
        }, 5000);
      }

      function createConfetti() {
        for (let index = 0; index < 70; index++) {
          const piece = document.createElement('div');
          piece.className = 'confetti';
          piece.style.left = `${Math.random() * 100}vw`;
          piece.style.animationDelay = `${Math.random() * 0.8}s`;
          piece.style.transform = `rotate(${Math.random() * 360}deg)`;
          document.body.appendChild(piece);

          setTimeout(() => {
            piece.remove();
          }, 4200);
        }
      }

      function escapeHtml(value) {
        return String(value || '')
          .replace(/&/g, '&amp;')
          .replace(/</g, '&lt;')
          .replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;')
          .replace(/'/g, '&#039;');
      }

      receptionForm.addEventListener('submit', handleSubmit);

      resetButton.addEventListener('click', () => {
        receptionForm.reset();

        document.getElementById('city').disabled = true;
        document.getElementById('city').innerHTML =
          '<option value="">先に都道府県を選択してください</option>';

        formView.style.display = 'block';
        successView.style.display = 'none';
        adminLink.style.display = 'block';
      });

      document.getElementById('prefecture').addEventListener('change', (event) => {
        renderCityOptions(event.target.value);
      });

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

ファイル名を間違えない

今回の Code.gs には、次のようなコードがあります。

const template = HtmlService.createTemplateFromFile('index');

これは、index という名前のHTMLファイルを読み込むという意味です。

そのため、ファイル名が違うと画面を表示できません。

ファイル名結果
index正しい
Index間違いになりやすい
index.html作成時の名前としては入力しない
form今回のコードとは合わない
受付今回のコードとは合わない

初心者の方は、必ず小文字で index にしてください。


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

index.html が作成できたら、中身をすべて削除して、完成版の index.html を貼り付けます。


手順

1. 左側の index.html をクリックする
2. 中央のコードをすべて選択する
3. 削除する
4. 完成版の index.html を貼り付ける
5. 保存する

ここに貼るもの

index.html には、先生から配布された完成版の index.html を貼り付けます。

教材上では、次の部分に貼り付けるコードを用意します。

ここに完成版の index.html を貼り付ける

貼り付け後に保存する

index.html も貼り付けたら、必ず保存します。

Windows:
Ctrl + S

Mac:
Command + S

5. 貼り付け後のファイル構成を確認する

ここまでできたら、左側のファイル一覧を確認します。

次の2つがあればOKです。

ファイルあるか確認
Code.gsある
index.htmlある

この2つがそろっていれば、次の節で初期設定を実行できます。


画面上の確認ポイント

確認する場所見る内容
左側のファイル一覧Code.gsindex.html がある
Code.gs完成版コードが貼られている
index.html完成版コードが貼られている
上部保存できている

6. 初心者が特に注意するポイント

1. 一部だけ貼り付けない

コードは一部だけでは動きません。

必ず、指定された完成コードを最初から最後まで全部コピーしてください。

悪い例:
途中からコピーする
途中で貼り付けが切れている
Code.gsの一部だけ貼る
index.htmlの一部だけ貼る

良い例:
完成コードを最初から最後まで全部コピーする

2. Code.gsとindex.htmlを逆に貼らない

Code.gs 用のコードを index.html に貼ると動きません。

逆に、index.html 用のコードを Code.gs に貼っても動きません。

貼る場所貼るコード
Code.gsconst SHEET_NAME = ... から始まるGASコード
index.html<!DOCTYPE html> から始まるHTMLコード

見分け方は簡単です。

Code.gs:
const や function が多い

index.html:
<!DOCTYPE html> や <html> がある

3. 保存を忘れない

コードを貼り付けても、保存していなければ反映されないことがあります。

貼り付けたら、毎回保存します。

貼る
↓
保存
↓
次の作業

この流れを習慣にします。


4. ファイル名を変えない

今回のコードでは、HTMLファイル名は index である必要があります。

Code.gs の中で、index という名前を指定しているからです。

HtmlService.createTemplateFromFile('index');

この 'index' とHTMLファイル名が一致していないと、画面が表示されません。


7. コードを貼っただけではまだ動かない

ここで注意です。

Code.gsindex.html を貼り付けても、まだ完成ではありません。

次の節で、初期設定のために setupApp() を実行する必要があります。

コードを貼る
↓
保存する
↓
setupAppを実行する
↓
Webアプリとして公開する

今は、コードを貼るところまでです。


8. よくあるつまずき

つまずき1:HTMLファイルの作り方がわからない

左側の「+」ボタンから作ります。

+
↓
HTML
↓
index

ファイル名は index です。


つまずき2:index.htmlではなくindexと入力するのが不安

Apps Scriptでは、HTMLファイル作成時に index と入力すれば大丈夫です。

作成後、左側には index.html のように表示されます。


つまずき3:コードを貼ったら赤い表示が出た

まず、次を確認してください。

確認内容
貼る場所Code.gsindex.html を逆にしていないか
コピー範囲途中で切れていないか
余計な文字#Code.gs#index.html まで貼っていないか
保存保存できているか

特に注意するのは、資料の見出しまで貼らないことです。

貼ってはいけない例:
#Code.gs
#index.html

コード本体だけを貼ります。


つまずき4:何を消してよいかわからない

Code.gs に最初から入っている次のコードは消して大丈夫です。

function myFunction() {

}

index.html を作った直後に入っている初期コードも、完成コードを貼るために消して大丈夫です。


9. わからない時にAIへ聞く方法

この節で困った場合は、AIに次のように聞きます。


質問例1:貼る場所がわからない

Google Apps Scriptで学校祭デジタル受付アプリを作っています。

Code.gsとindex.htmlに完成コードを貼り付ける作業をしています。

困っていること:
どちらのコードをどのファイルに貼ればよいかわかりません。

Code.gsは const SHEET_NAME から始まるコードです。
index.htmlは <!DOCTYPE html> から始まるコードです。

初心者にもわかるように、貼る場所を説明してください。

質問例2:赤いエラーが出た

Google Apps Scriptで学校祭デジタル受付アプリを作っています。

完成コードを貼り付けたら、赤いエラー表示が出ました。

今の状態:
Code.gsにコードを貼りました。

エラー文:
ここにエラー文を貼る

初心者にもわかるように、何を確認すればよいか教えてください。

質問例3:ファイル名が正しいか不安

Google Apps ScriptでHTMLファイルを追加しました。

学校祭デジタル受付アプリでは、HTMLファイル名を index にする必要があると聞きました。

左側には index.html と表示されています。

これは正しい状態ですか。
初心者にもわかるように説明してください。

10. この節のまとめ

この節では、完成コードをGoogle Apps Scriptに貼り付けました。

大切なポイントは、次の通りです。

  • 最初はコードを全部理解しなくてよい。
  • まず完成コードをコピペして、動く状態を作る。
  • Code.gs にはGAS用のコードを貼る。
  • index.html にはHTML用のコードを貼る。
  • Code.gsindex.html を逆に貼らない。
  • HTMLファイル名は index にする。
  • 貼り付け後は必ず保存する。
  • 資料の見出しである #Code.gs#index.html は貼らない。
  • コードを貼っただけではまだ動かない。
  • 次の節で setupApp() を実行する。

確認問題

問題1

今回、Google Apps Scriptで使うファイルは何と何ですか。

回答

Code.gsindex.html です。


問題2

Code.gs に貼るコードは、どのような見た目から始まりますか。

回答

const SHEET_NAME = '受付データ'; のように、constfunction が多いコードです。


問題3

index.html に貼るコードは、どのような見た目から始まりますか。

回答

<!DOCTYPE html> から始まるHTMLコードです。


問題4

HTMLファイルを追加するとき、ファイル名は何にしますか。

回答

index にします。


問題5

完成コードを貼ったあと、必ず行うことは何ですか。

回答

保存することです。Windowsなら Ctrl + S、Macなら Command + S で保存します。

教材トップへ戻る