체육대회 웹앱 만들기(바이브 코딩)

작성자 이준용 · 2026-06-19

예시 웹앱 사이트(래퍼런스) : 2026 세현고 체육한마당

안녕하세요 체육대회를 할 때 아무리 안내를 해도 학생들은 다음은 뭐하는지, 종목 배점은 어떻게 되는지, 어디서 모이는지 등 물어봅니다. 체육대회를 운영하는 입장에서 미리 잘 정리된 웹앱이 있다면 학생들도, 교사도 편할 겁니다. 그래서 체육대회 웹앱 만드는 방법을 공유드립니다.

아래 설명 및 사진 순서에 따라 만드시면 됩니다.

1단계: 구글 스프레드시트 세팅하기

먼저 구글 스프레드시트를 만들고, 아래 8개의 탭(시트)을 만들고 1행(제목줄)을 똑같이 적어주세요. (또는 아래 구글 스프레드시트: 사본 만들기 또는 생성형 AI에게 복사 붙여넣기 하여 학교에 맞게 수정.)

https://docs.google.com/spreadsheets/d/10VqLK2Thl0xx8Wdl2iBQ2dTuBeZj8wtEUoF-NJ9-UBU/edit?usp=sharing

2단계: AI에게 마스터 프롬프트 복사/붙여넣기 ChatGPT, Claude, Gemini 등 코딩이 가능한 AI를 열고, 아래의 박스 안의 텍스트에서 [학교 이름]만 본인의 학교로 수정한 뒤 그대로 복사해서 붙여넣으세요.

너는 10년 차 수석 프론트엔드 개발자이자 UX/UI 디자이너야.

나는 구글 앱스 스크립트(Google Apps Script)를 활용하여 [학교 이름]의 체육대회 안내 모바일 웹앱을 만들려고 해.

백엔드 역할을 할 Code.gs와 프론트엔드 역할을 할 Index.html 코드를 완벽하게(생략 없이) 전체 코드로 작성해 줘.

시행착오를 겪고 싶지 않으니, 아래의 [구현 조건]과 [탭별 특수 로직]을 100% 엄격하게 반영해야 해.

# [1. 백엔드: Code.gs 로직]

1. doGet() 함수를 사용해 'Index' HTML을 불러올 것. (모바일 viewport 세팅 필수)

2. getSheetData(sheetName) 함수를 작성하여 구글 스프레드시트의 데이터를 JSON(배열 안의 객체) 형태로 반환할 것. 빈 줄은 제외할 것.

# [2. 프론트엔드: UI/UX 디자인 원칙]

1. Tailwind CSS (CDN)와 FontAwesome (CDN)을 사용할 것.

2. 폰트는 'Pretendard'를 기본으로 사용할 것.

3. 컬러 시스템 (60-30-10 원칙): 네이비(주조색), 화이트/라이트그레이(배경), 앰버 골드/에메랄드(강조색) 사용.

4. 모바일 친화적 SPA(Single Page Application) 구조: 상단에 가로 스크롤이 가능한 탭 메뉴를 고정(Sticky)하고, 탭 클릭 시 해당 내용만 tab-content 영역에 부드럽게 렌더링할 것.

5. 에러 방지: 데이터 로딩 시 흰 화면이 뜨지 않도록 window.onerror 및 try-catch 로직을 넣고, 실패 시 화면에 붉은 박스로 에러 원인을 띄워줄 것. 또한 모든 텍스트 출력 전 undefined를 방지하는 safeStr() 함수를 거치도록 할 것.

# [3. 프론트엔드: 7개 탭별 특수 렌더링 로직 (매우 중요)]

데이터(data)를 받아 각 탭의 이름에 따라 아래 스타일로 화면에 그려야 해.

1. 일정 탭:

- '오전', '점심', '오후' 데이터 배열을 나누어 렌더링.

- 청팀, 홍팀, 백팀, VS, 승자팀, 1경기 등의 텍스트는 각각의 고유한 배경색이 있는 둥근 배지(Chip) 형태로 디자인을 치환할 것.

- 종목 이름 위에는 종목에 맞는 이모티콘을 크게 배치할 것.

- 점심시간은 일반 표가 아닌, 둥글고 큰 강조형 카드(그라데이션 배경)로 따로 뺄 것.

2. 팀구성 탭:

- 학년별로 구역을 나누고, 청/홍/백 팀에 맞춰 테두리와 배경색(파랑/빨강/회색)을 다르게 렌더링할 것.

3. 규칙 탭:

- 종목별로 카드 UI를 만들고, 내용 중 "준비물:"로 시작하는 줄은 카드 하단에 별도의 회색 박스로 강조할 것.

4. 배점 탭:

- 먼저 '팀점수' 시트 데이터를 불러와서, 1학년/2학년을 분리하여 점수(내림차순)대로 1,2,3위(🥇🥈🥉) 메달 배지와 함께 순위 카드를 띄울 것.

- 화면 새로고침 버튼을 넣고, 그 아래에 '배점' 시트의 배점표(종목, 1위, 2위, 3위 열 순서 고정)를 표 형태로 출력할 것.

5. 장소 탭:

- 맨 위에 전체 경기장 배치도를 보여주는 이미지 태그(<img src="...">) 영역을 카드로 만들고, 그 아래에 구역별 설명을 2열 그리드(Grid) 카드로 나열할 것.

6. 심판 탭:

- 화면 상단에 '전체' 및 '종목별' 필터링 버튼들을 가로로 나열할 것.

- 심판 카드는 [진행 총괄‧주심]과 [진행 보조‧부심] 2개의 항목으로 상하 분리하여 깔끔하게 보여줄 것.

7. 명단 탭:

- 학년 선택 버튼(1학년, 2학년)과 반 선택 버튼(1~6반)을 만들어 상태(currentGrade, currentClass)를 관리할 것.

- 버튼을 누를 때마다 해당 학년/반의 담임교사 이름과 종목별 참가 학생 명단을 필터링하여 보여줄 것.

- 참가자 이름은 쉼표(,)를 기준으로 나누어 각각 둥근 칩(Chip) 형태로 나열하고, 참가자에 "전원"이라는 단어가 있으면 "🏫 학급 전원 참여"라는 특수 배지로 띄울 것. 우측 상단에는 참가 총인원수를 배지로 띄울 것.

이 모든 요구사항을 완벽히 통합하여, 복사 후 구글 앱스 스크립트에 바로 붙여넣어 실행할 수 있는 완성된 코드를 제시해 줘.


3단계: 구글 앱스 스크립트에 코드 붙여넣고 배포하기

  1. AI가 완성해 준 코드를 복사하여, 구글 스프레드시트의 [확장 프로그램] -> [Apps Script]로 들어갑니다.

  2. Code.gs와 Index.html 파일에 각각 코드를 덮어쓰기 합니다.

우측 상단 [배포] -> [새 배포]를 누르고, 권한을 반드시 "모든 사용자(Anyone)"로 설정하여 배포하면 끝납니다!

4단계: 래퍼런스(예시 사진 등)를 첨부하여 AI와 작업- 테스트 배포 후 화면에서 새로고침하며 수정사항 확인.

자료: 래퍼런스(세현고 예시)의 구글 앱 스크립트 코드

Code.js

function doGet() {

  return HtmlService.createHtmlOutputFromFile('Index')

    .setTitle('2026 세현 스포츠대축전')

    .addMetaTag('viewport', 'width=device-width, initial-scale=1')

    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);

}


function getSheetData(sheetName) {

  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(sheetName);

  if (!sheet) return [];

  var data = sheet.getDataRange().getValues();

  if (data.length <= 1) return [];

 

  var headers = data[0];

  var result = [];

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

    var row = data[i];

    var obj = {};

    var isEmptyRow = true;

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

      obj[headers[j]] = row[j];

      if (row[j] !== "") isEmptyRow = false;

    }

    if (!isEmptyRow) result.push(obj);

  }

  return result;

}


Index.html

<!DOCTYPE html>

<html lang="ko">

<head>

  <meta charset="UTF-8">

  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

  <base target="_top">

  <script src="https://cdn.tailwindcss.com"></script>

  <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">

  <style>

    @import url('https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;700;900&display=swap');

    body { font-family: 'Pretendard', sans-serif; background-color: #f3f4f6; -webkit-tap-highlight-color: transparent; }

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

    .scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }

    .nav-active { color: #1e293b !important; font-weight: 900 !important; border-bottom: 4px solid #1e293b; }

    .card-hover { transition: all 0.3s ease; }

    .card-hover:hover { transform: translateY(-2px); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); }

    .schedule-table th:first-child { border-top-left-radius: 12px; }

    .schedule-table th:last-child { border-top-right-radius: 12px; }

  </style>

</head>

<body class="text-gray-800 antialiased pb-24">


  <div id="debug-screen" class="hidden p-6 bg-red-50 text-red-600 font-bold border-b border-red-200 text-sm whitespace-pre-wrap"></div>


  <header class="bg-gradient-to-r from-slate-800 to-slate-700 text-white pt-10 pb-6 px-6 shadow-md rounded-b-3xl relative overflow-hidden">

    <div class="absolute top-0 right-0 -mt-10 -mr-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl"></div>

    <div class="relative z-10 text-center">

      <p class="text-amber-400 font-bold text-sm tracking-widest mb-1">2026 SEHYUN SPORTS FESTIVAL</p>

      <h1 class="text-3xl font-black tracking-tight mb-2">세현 스포츠대축전</h1>

      <p class="text-slate-200 text-sm font-medium">함께 뛰는 우리, 하나 되는 세현</p>

    </div>

  </header>


  <nav class="sticky top-0 bg-white/90 backdrop-blur-md shadow-sm z-50 border-b border-gray-200">

    <div class="flex overflow-x-auto scrollbar-hide px-2">

      <button onclick="showTab('일정')" id="btn-일정" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">일정</button>

      <button onclick="showTab('팀구성')" id="btn-팀구성" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">팀구성</button>

      <button onclick="showTab('규칙')" id="btn-규칙" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">규칙</button>

      <button onclick="showTab('배점')" id="btn-배점" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">배점</button>

      <button onclick="showTab('장소')" id="btn-장소" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">장소</button>

      <button onclick="showTab('심판')" id="btn-심판" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">심판</button>

      <button onclick="showTab('명단')" id="btn-명단" class="nav-item flex-shrink-0 py-4 px-5 text-gray-400 font-medium whitespace-nowrap transition-colors">명단</button>

    </div>

  </nav>


  <main class="container mx-auto px-4 pt-6 max-w-4xl">

    <div id="loading" class="flex flex-col items-center justify-center py-20 hidden">

      <i class="fa-solid fa-spinner fa-spin text-4xl text-slate-300 mb-4"></i>

      <p class="text-gray-500 font-medium">데이터를 불러오는 중입니다...</p>

    </div>

    <div id="tab-content"></div>

  </main>


  <script>

    // 안전장치 1: 데이터 오류 시 화면 멈춤 방지

    window.onerror = function(msg, url, line) {

      document.getElementById('debug-screen').classList.remove('hidden');

      document.getElementById('debug-screen').innerHTML += ⚠️ 스크립트 에러 발생: ${msg} (줄번호: ${line})\n;

    };


    function safeStr(val) { return String(val || ''); }


    let globalRosterData = [];

    let currentGrade = '1학년';

    let currentClass = '1반';


    window.onload = () => showTab('일정');


    function showTab(tabName) {

      document.getElementById('loading').classList.remove('hidden');

      document.getElementById('tab-content').classList.add('hidden');

     

      document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('nav-active'));

      const activeBtn = document.getElementById('btn-' + tabName);

      if(activeBtn) {

        activeBtn.classList.add('nav-active');

        activeBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });

      }


      if (tabName === '배점') {

        fetchScoreData();

      } else {

        google.script.run

          .withSuccessHandler((data) => {

            renderData(tabName, data);

            document.getElementById('loading').classList.add('hidden');

            document.getElementById('tab-content').classList.remove('hidden');

          })

          .withFailureHandler((err) => {

            document.getElementById('loading').innerHTML = <div class="bg-rose-50 border border-rose-200 p-6 rounded-2xl text-center"><i class="fa-solid fa-triangle-exclamation text-3xl text-rose-500 mb-3"></i><h3 class="text-rose-800 font-bold text-lg mb-1">데이터를 불러올 수 없습니다.</h3><p class="text-gray-600 text-sm">${err.message}</p></div>;

          })

          .getSheetData(tabName);

      }

    }


    function renderData(tabName, data) {

      const container = document.getElementById('tab-content');

      let html = <div class="flex items-center mb-6"><div class="w-1.5 h-6 bg-slate-800 rounded-full mr-3"></div><h2 class="text-2xl font-bold text-gray-800 tracking-tight">${tabName}</h2></div>;


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

        html += <div class="bg-white rounded-2xl p-10 text-center shadow-sm border border-gray-100"><i class="fa-regular fa-folder-open text-5xl text-gray-300 mb-4"></i><p class="text-gray-500 font-medium">아직 등록된 정보가 없습니다.</p></div>;

      } else if (tabName === '일정') { html += renderTimeline(data); }

      else if (tabName === '팀구성') { html += renderTeamComposition(data); }

      else if (tabName === '규칙') { html += renderRulesTable(data); }

      else if (tabName === '심판') { html += renderRefereeTab(data); }

      else if (tabName === '장소') { html += renderVenueTab(data); }

      else if (tabName === '명단') {

        globalRosterData = data;

        html += <div id="roster-container"></div>;

        setTimeout(updateRosterUI, 10);

      }

      else { html += renderGeneralTable(data); }

      container.innerHTML = html;

    }


    /* ----------------------------------------------------

       ✨ 일정 탭 전용 렌더링

    ---------------------------------------------------- */

    function renderTimeline(data) {

      let html = '<div class="space-y-6 animate-fade-in">';

      let m = data.filter(d => safeStr(d['구분']).includes('오전'));

      let l = data.filter(d => safeStr(d['구분']).includes('점심'));

      let af = data.filter(d => safeStr(d['구분']).includes('오후'));


      if (m.length > 0) {

        html += <div class="flex items-center mb-3 ml-1 mt-2"><div class="bg-amber-100 p-2.5 rounded-xl mr-3 shadow-sm"><i class="fa-regular fa-sun text-amber-600 text-xl"></i></div><h3 class="text-[20px] font-black text-slate-800 tracking-tight">오전 경기</h3></div> + buildMatrix(m, 'bg-slate-800');

      }


      if (l.length > 0) {

        html += <div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-[24px] border border-amber-200 p-8 text-center shadow-sm my-8 card-hover relative overflow-hidden"><div class="absolute -top-4 -right-2 opacity-10 text-8xl transform rotate-12">🍱</div><div class="text-5xl mb-4 drop-shadow-md relative z-10">🍱</div><div class="text-[24px] font-black text-amber-900 mb-1 tracking-tight relative z-10">${safeStr(l[0]['시간'])}</div><div class="text-[16px] text-amber-700 font-bold mb-2 relative z-10">${safeStr(l[0]['종목'])}</div><div class="text-[13px] text-amber-600/80 font-medium relative z-10">${safeStr(l[0]['1학년'])}</div></div>;

      }


      if (af.length > 0) {

        html += <div class="flex items-center mb-3 mt-10 ml-1"><div class="bg-rose-100 p-2.5 rounded-xl mr-3 shadow-sm"><i class="fa-solid fa-fire text-rose-600 text-xl"></i></div><h3 class="text-[20px] font-black text-slate-800 tracking-tight">오후 경기</h3></div> + buildMatrix(af, 'bg-rose-900');

      }

      return html + '</div>';

    }


    function buildMatrix(rs, cl) {

      let h = <div class="overflow-hidden rounded-[20px] shadow-sm border border-gray-200 bg-white"><table class="min-w-full text-center divide-y divide-gray-200 schedule-table"><thead class="${cl} text-white text-[13px] tracking-wider"><tr><th class="py-3.5 px-2 font-bold w-[18%]">시간</th><th class="py-3.5 px-2 border-l border-white/10 w-[22%]">종목</th><th class="py-3.5 px-2 border-l border-white/10 w-[30%]">1학년</th><th class="py-3.5 px-2 border-l border-white/10 w-[30%]">2학년</th></tr></thead><tbody class="divide-y divide-gray-100 text-[14px]">;

      rs.forEach(r => {

        let rawG1 = safeStr(r['1학년']).replace(/\n/g, '<br>');

        let rawG2 = safeStr(r['2학년']).replace(/\n/g, '<br>');

        let style = (t) => t.replace(/vs/gi, '<span class="mx-1 px-1.5 py-0.5 bg-gray-100 text-gray-400 text-[10px] rounded-md font-black shadow-sm">VS</span>')

                            .replace(/청팀/g, '<span class="text-blue-700 font-black bg-blue-50 px-2 py-0.5 rounded-md border border-blue-100">청팀</span>')

                            .replace(/홍팀/g, '<span class="text-rose-700 font-black bg-rose-50 px-2 py-0.5 rounded-md border border-rose-100">홍팀</span>')

                            .replace(/백팀/g, '<span class="text-slate-600 font-black bg-slate-100 px-2 py-0.5 rounded-md border border-slate-200">백팀</span>')

                            .replace(/승자팀/g, '<span class="text-emerald-700 font-black bg-emerald-50 px-2 py-0.5 rounded-md border border-emerald-100">승자팀</span>')

                            .replace(/패자팀/g, '<span class="text-purple-700 font-black bg-purple-50 px-2 py-0.5 rounded-md border border-purple-100">패자팀</span>')

                            .replace(/1경기|2경기|3경기/g, m => <span class="block text-slate-800 font-bold mt-2 mb-1 text-[13px] border-b border-gray-100 pb-0.5">${m}</span>);

       

        let g1 = style(rawG1);

        let g2 = style(rawG2);

        let eventText = safeStr(r['종목']);

        let icon = getEventIcon(eventText);

        let eventName = <div class="flex flex-col items-center justify-center py-1"><span class="text-2xl mb-1 drop-shadow-sm">${icon}</span><span class="font-extrabold text-slate-800 text-[15px] leading-tight">${eventText.replace(/\n/g, '<br>')}</span></div>;

       

        h += <tr class="hover:bg-slate-50 transition-colors duration-200"><td class="py-4 px-2 font-extrabold text-slate-500 bg-slate-50/50 text-[12px] leading-relaxed">${safeStr(r['시간']).replace(/\n/g, '<br>')}</td><td class="py-4 px-1 border-l border-gray-100">${eventName}</td>;

        if (g1 === g2 && g1 !== '') { h += <td colspan="2" class="py-4 px-3 text-gray-700 border-l border-gray-100 bg-emerald-50/30 leading-relaxed">${g1}</td>; }

        else { h += <td class="py-4 px-3 text-gray-700 border-l border-gray-100 leading-relaxed">${g1}</td><td class="py-4 px-3 text-gray-700 border-l border-gray-100 leading-relaxed">${g2}</td>; }

        h += </tr>;

      });

      return h + </tbody></table></div>;

    }


    function getEventIcon(name) {

      let n = safeStr(name);

      if(n === '') return '📌';

      if(n.includes('판뒤집기')) return '🟥🟦';

      if(n.includes('줄넘기')) return '➰';

      if(n.includes('2인 3각') || n.includes('2인3각')) return '🏃‍♂️';

      if(n.includes('태풍')) return '🌀';

      if(n.includes('줄다리기')) return '🪢';

      if(n.includes('계주')) return '🏃‍♀️';

      if(n.includes('축구')) return '⚽';

      if(n.includes('피구')) return '🏐';

      return '📌';

    }


    /* ----------------------------------------------------

       나머지 탭들 (오류 방지용 문자열 변환 적용)

    ---------------------------------------------------- */

    function renderVenueTab(data) {

      const IMAGE_URL = "https://images.unsplash.com/photo-1574629810360-7efbb1925846?q=80&w=1000&auto=format&fit=crop";

      let html = <div class="space-y-6 animate-fade-in"><div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-gray-200"><div class="bg-slate-100 p-4 border-b border-gray-200 flex justify-between items-center"><span class="font-bold text-slate-700"><i class="fa-solid fa-map-location-dot mr-2 text-emerald-600"></i>전체 경기장 배치도</span></div><div class="p-3 bg-gray-50"><img src="${IMAGE_URL}" class="w-full h-auto rounded-2xl shadow-sm border border-gray-200" alt="Venue Layout Map"></div></div><div class="grid gap-4 sm:grid-cols-2">;

      data.forEach(item => { let entries = Object.entries(item); if(entries.length < 2) return; html += <div class="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm card-hover"><h3 class="font-bold text-lg text-slate-800 mb-3 pb-2 border-b border-gray-100">${safeStr(entries[0][1])}</h3><p class="text-[15px] text-gray-600 leading-relaxed">${safeStr(entries[1][1]).replace(/\n/g, '<br>')}</p></div>; });

      return html + '</div></div>';

    }


    function updateRosterUI() {

      const container = document.getElementById('roster-container');

      if(!container) return;

      const filteredData = globalRosterData.filter(d => safeStr(d['학년']) === currentGrade && safeStr(d['반']) === currentClass);

      let teacherName = filteredData.length > 0 ? safeStr(filteredData[0]['담임교사']) : '';

      let html = <div class="mb-4"><div class="text-[13px] font-bold text-gray-500 mb-2 ml-1">학년 선택</div><div class="grid grid-cols-2 gap-2"><button class="py-3 rounded-xl text-[15px] font-bold transition-colors ${currentGrade === '1학년' ? 'bg-emerald-800 text-white shadow-md' : 'bg-white text-gray-600 border border-gray-200'}">1학년</button><button class="py-3 rounded-xl text-[15px] font-bold transition-colors ${currentGrade === '2학년' ? 'bg-emerald-800 text-white shadow-md' : 'bg-white text-gray-600 border border-gray-200'}">2학년</button></div></div>;

      html += <div class="mb-6"><div class="text-[13px] font-bold text-gray-500 mb-2 ml-1">반 선택</div><div class="grid grid-cols-3 sm:grid-cols-6 gap-2">;

      for(let i=1; i<=6; i++) { let cls = ${i}반; let active = currentClass === cls ? 'bg-emerald-800 text-white shadow-md border-emerald-800' : 'bg-white text-gray-600 border-gray-200'; html += <button class="py-2.5 rounded-xl text-sm font-bold transition-colors border ${active}">${cls}</button>; }

      html += </div></div>;

      html += <div class="flex items-center text-slate-700 mb-4 pb-2 border-b border-gray-200"><span class="text-xl mr-2">📋</span><h3 class="text-lg font-extrabold mr-3 text-emerald-800">${currentGrade} ${currentClass}</h3><span class="text-[14px] font-medium text-gray-500 border-l border-gray-300 pl-3">담임: <b class="text-gray-700">${teacherName || '미정'}</b> 선생님</span></div>;

      if (filteredData.length === 0) { html += <div class="bg-gray-50 rounded-xl p-8 text-center border border-gray-200 text-gray-400 text-sm">등록된 명단 데이터가 없습니다.</div>; }

      else {

        html += <div class="space-y-4">; const colors = ['bg-emerald-700', 'bg-rose-700', 'bg-amber-600', 'bg-blue-700', 'bg-purple-600', 'bg-slate-700'];

        filteredData.forEach((row, index) => {

          let event = safeStr(row['종목']); let namesStr = safeStr(row['참가자'] || row['명단']); if(!event) return;

          let colorClass = colors[index % colors.length]; let names = []; let countText = '';

          if (namesStr.includes('전원')) { countText = '전체 인원'; } else { names = namesStr.split(',').map(n => n.trim()).filter(n => n !== ''); countText = names.length > 0 ? ${names.length}명 : '0명'; }

          let icon = getEventIcon(event);

          html += <div class="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm"><div class="${colorClass} text-white px-4 py-3 flex justify-between items-center"><h4 class="font-extrabold text-[16px] tracking-tight flex items-center"><span class="mr-2 text-lg">${icon}</span> ${event} <span class="mx-2 opacity-50">|</span> <span class="font-medium text-sm opacity-90">${currentGrade} ${currentClass}</span></h4><span class="bg-black/20 px-2.5 py-1 rounded-lg text-xs font-bold shadow-inner backdrop-blur-sm">${countText}</span></div><div class="p-4 bg-gray-50/50"><div class="text-[12px] font-bold text-gray-400 mb-3 ml-1">참가 명단</div>;

          if (namesStr.includes('전원')) { html += <div class="flex items-center text-emerald-700 bg-emerald-50 px-3 py-2 rounded-lg border border-emerald-100 font-bold text-sm"><span class="mr-2">🏫</span> 학급 전원 참여</div>; }

          else if (names.length === 0) { html += <div class="text-sm text-gray-400 ml-1">명단이 없습니다.</div>; }

          else { html += <div class="flex flex-wrap gap-2">; names.forEach(name => { html += <span class="bg-white border border-gray-200 text-gray-700 px-3 py-1.5 rounded-full text-[14px] font-semibold shadow-sm">${name}</span>; }); html += </div>; }

          html += </div></div>;

        }); html += </div>;

      }

      container.innerHTML = html;

    }

    window.setRosterGrade = function(grade) { currentGrade = grade; updateRosterUI(); };

    window.setRosterClass = function(cls) { currentClass = cls; updateRosterUI(); };


    function renderRefereeTab(data) {

      const events = ['전체', ...new Set(data.map(d => safeStr(d['종목'])).filter(Boolean))];

      let html = <div class="bg-amber-50 border border-amber-200 rounded-xl p-4 mb-6 flex items-start shadow-sm"><span class="text-xl mr-3 mt-0.5">💡</span><p class="text-gray-700 font-medium text-[14px] leading-relaxed">진행 보조·부심 학생 2명은 예선 1경기는 2경기 팀 소속 반장·부반장, 2경기는 1경기 팀 소속 반장·부반장이 맡습니다.</p></div><div class="flex overflow-x-auto gap-2.5 mb-6 scrollbar-hide pb-2 px-1">;

      events.forEach((ev, idx) => { let activeClass = idx === 0 ? 'bg-emerald-800 text-white border-emerald-800' : 'bg-white text-gray-500 border-gray-200'; html += <button class="referee-btn flex-shrink-0 px-4 py-2 rounded-full text-sm font-bold shadow-sm transition-colors border ${activeClass}">${ev}</button>; });

      html += </div><div id="referee-cards" class="space-y-4">;

      const colors = ['bg-amber-500', 'bg-purple-600', 'bg-blue-600', 'bg-emerald-600', 'bg-rose-500', 'bg-slate-700'];

      data.forEach((row, index) => {

        let event = safeStr(row['종목'] || '행사'); let time = safeStr(row['시간'] || '-'); let head = safeStr(row['진행 총괄‧주심'] || row['진행 총괄·주심'] || row['진행 총괄'] || '-'); let assist = safeStr(row['진행 보조‧부심'] || row['진행 보조·부심'] || row['진행 보조'] || '-'); let colorClass = colors[index % colors.length];

        html += <div class="referee-card border border-gray-200 rounded-2xl overflow-hidden shadow-sm bg-white" data-event="${event}"><div class="${colorClass} text-white px-5 py-3.5 flex justify-between items-center"><h3 class="font-extrabold text-[17px] tracking-tight">${event}</h3><span class="bg-white/20 px-3 py-1 rounded-full text-[13px] font-bold shadow-inner">${time}</span></div><div class="p-5"><div class="space-y-4"><div><div class="text-[13px] font-extrabold text-blue-600 mb-1">진행 총괄‧주심</div><div class="text-gray-800 font-medium text-[15px] pl-1 border-l-2 border-blue-200">${head}</div></div><div><div class="text-[13px] font-extrabold text-blue-600 mb-1">진행 보조‧부심</div><div class="text-gray-800 font-medium text-[15px] pl-1 border-l-2 border-blue-200">${assist}</div></div></div></div></div>;

      }); return html + </div>;

    }

    window.filterReferee = function(eventName, btn) {

      document.querySelectorAll('.referee-btn').forEach(b => { b.classList.remove('bg-emerald-800', 'text-white', 'border-emerald-800'); b.classList.add('bg-white', 'text-gray-500', 'border-gray-200'); });

      btn.classList.remove('bg-white', 'text-gray-500', 'border-gray-200'); btn.classList.add('bg-emerald-800', 'text-white', 'border-emerald-800');

      document.querySelectorAll('.referee-card').forEach(card => { if (eventName === '전체' || card.getAttribute('data-event') === eventName) card.style.display = 'block'; else card.style.display = 'none'; });

    };


    function fetchScoreData() {

      document.getElementById('loading').classList.remove('hidden'); document.getElementById('tab-content').classList.add('hidden');

      google.script.run.withSuccessHandler((teamScores) => {

        google.script.run.withSuccessHandler((criteria) => {

          renderScoreTab(teamScores, criteria);

          document.getElementById('loading').classList.add('hidden');

          document.getElementById('tab-content').classList.remove('hidden');

        }).withFailureHandler((err) => showError(err)).getSheetData('배점');

      }).withFailureHandler((err) => showError(err)).getSheetData('팀점수');

    }


    function renderScoreTab(teamScores, criteria) {

      const container = document.getElementById('tab-content');

      let now = new Date(); let timeString = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0') + ':' + now.getSeconds().toString().padStart(2,'0');

      let html = <h2 class="text-[22px] font-bold text-gray-800 tracking-tight mb-2 flex items-center"><span class="text-amber-500 mr-2 text-2xl">🏆</span> 실시간 팀 점수 현황</h2><div class="text-right mb-2 text-xs text-gray-500 font-medium">업데이트: ${timeString}</div>;

      const grades = ["1학년", "2학년"];

      grades.forEach(grade => {

        const gradeScores = teamScores.filter(s => safeStr(s['학년']) === grade); html += <h3 class="text-lg font-bold text-slate-700 mb-4 mt-6 ml-1">${grade} 점수 순위</h3>;

        if (gradeScores.length === 0) { html += <div class="bg-white rounded-xl p-6 text-center border text-gray-400 text-sm mb-4">해당 학년의 데이터가 없습니다.</div>; }

        else {

          let sorted = [...gradeScores].map(t => ({ 팀명: safeStr(t['팀명']), 점수: Number(t['점수']) })).sort((a,b) => b.점수 - a.점수); html += <div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">;

          sorted.forEach((t, index) => {

            let rank = index + 1; if(index > 0 && t.점수 === sorted[index-1].점수) rank = sorted[index-1]._rank; t._rank = rank;

            let s = getTeamStyle(t.팀명); let medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : '🥉';

            html += <div class="${s.bg} border-2 ${s.border} rounded-3xl p-5 shadow-sm relative card-hover"><div class="flex items-center mb-2"><span class="text-xl mr-2">${s.icon}</span><h4 class="text-lg font-bold ${s.text}">${t.팀명}</h4></div><div class="text-3xl font-black ${s.text} mb-2 leading-none">${t.점수}<span class="text-sm font-bold ml-1">점</span></div><div class="inline-flex items-center ${s.badge} px-2.5 py-1 rounded-lg text-xs font-extrabold shadow-sm">${medal} ${rank}위</div></div>;

          }); html += </div>;

        }

      });

      html += <button class="w-full py-4 bg-white border border-gray-300 rounded-2xl shadow-sm text-blue-600 font-bold hover:bg-blue-50 transition flex justify-center items-center mb-10 card-hover active:bg-blue-100"><i class="fa-solid fa-arrows-rotate mr-2"></i> 실시간 새로고침</button>;

      html += renderCriteriaTable(criteria);

      html += <div class="bg-emerald-50 border border-emerald-200 rounded-2xl p-6 shadow-sm mt-8"><h4 class="text-emerald-800 font-extrabold mb-3 flex items-center"><span class="text-2xl mr-2">💡</span> 최종 순위 산정 방식</h4><p class="text-gray-700 font-medium leading-relaxed mb-2 text-[15px]">각 종목별 학년 경기 결과 배점을 모두 합산하여 청·홍·백팀의 최종 순위를 결정합니다.</p><p class="text-gray-700 font-medium leading-relaxed text-[15px]">최종 점수가 동점인 경우 <span class="font-bold text-emerald-700 border-b-2 border-emerald-300">계주 순위</span>에 따라 순위가 결정됩니다.</p></div>;

      container.innerHTML = html;

    }

    function getTeamStyle(name) {

      let n = safeStr(name);

      if(n.includes('청')) return {border: 'border-blue-400', bg: 'bg-blue-50/50', text: 'text-blue-700', icon: '🌊', badge: 'bg-blue-100 text-blue-800'};

      if(n.includes('홍')) return {border: 'border-rose-400', bg: 'bg-rose-50/50', text: 'text-rose-700', icon: '🔥', badge: 'bg-rose-100 text-rose-800'};

      if(n.includes('백')) return {border: 'border-slate-300', bg: 'bg-slate-50', text: 'text-slate-700', icon: '☁️', badge: 'bg-slate-200 text-slate-800'};

      return {border: 'border-gray-200', bg: 'bg-white', text: 'text-gray-800', icon: '⭐', badge: 'bg-gray-100 text-gray-800'};

    }

    function renderCriteriaTable(criteria) {

      if(!criteria || criteria.length === 0) return '';

      const targetOrder = ['종목', '1위(승)', '2위(패)', '3위']; let actualKeys = Object.keys(criteria[0]); let hs = targetOrder.filter(k => actualKeys.includes(k)); actualKeys.forEach(k => { if(!hs.includes(k)) hs.push(k); });

      let html = <h3 class="text-lg font-bold text-slate-700 mb-3 ml-1">배점 기준</h3><div class="overflow-hidden rounded-2xl shadow-sm border border-gray-200 bg-white"><table class="min-w-full text-center divide-y divide-gray-200"><thead class="bg-emerald-800 text-white text-sm"><tr>${hs.map(h => <th class="py-3 px-2 font-bold">${h}</th>).join('')}</tr></thead><tbody class="divide-y divide-gray-100 text-sm text-gray-700">;

      criteria.forEach(row => { html += <tr class="hover:bg-gray-50">${hs.map((h, i) => <td class="py-4 px-2 ${i===0 ? 'font-bold text-slate-800':''} whitespace-pre-wrap">${safeStr(row[h])}</td>).join('')}</tr>; }); return html + </tbody></table></div>;

    }


    function renderRulesTable(data) {

      let html = '<div class="space-y-5">'; data.forEach(row => {

        let title = safeStr(row['종목'] || Object.values(row)[0] || ''); let content = safeStr(row['규칙내용'] || row['규칙'] || Object.values(row)[1] || ''); let icon = getEventIcon(title); let lines = content.split('\n').map(l => l.trim()).filter(l => l !== ''); let contentHtml = ''; let prep = ''; let step = 1;

        lines.forEach(l => { if (l.startsWith('준비물:')) prep = l; else if (l.endsWith(':')) contentHtml += <div class="mt-5 mb-3 text-sm font-extrabold text-rose-600 bg-rose-50 inline-block px-2.5 py-1 rounded-md border border-rose-100">${l}</div>; else contentHtml += <div class="flex items-start mb-3.5"><div class="bg-emerald-700 text-white font-bold rounded-full w-[22px] h-[22px] flex items-center justify-center flex-shrink-0 mt-0.5 text-xs mr-3 border border-emerald-800">${step++}</div><p class="text-gray-700 font-medium leading-relaxed text-[15px] pt-0.5">${l}</p></div>; });

        if (prep) contentHtml += <div class="mt-5 pt-3 border-t border-gray-100 text-[13px] text-slate-500 font-medium bg-slate-50 p-2 rounded-lg"><i class="fa-solid fa-box-open mr-2 text-slate-400"></i>${prep}</div>;

        html += <div class="bg-white rounded-3xl p-6 shadow-sm border border-gray-200 card-hover"><h3 class="text-xl font-extrabold text-slate-800 mb-5 pb-4 border-b border-gray-100 flex items-center"><span class="text-3xl mr-3">${icon}</span> <span class="text-emerald-800 tracking-tight">${title}</span></h3><div class="pl-1">${contentHtml}</div></div>;

      }); return html + '</div>';

    }


    function renderTeamComposition(data) {

      let html = <div class="bg-amber-50 border border-amber-200 rounded-2xl p-5 mb-8 flex items-start shadow-sm"><span class="text-2xl mr-3">💡</span><p class="text-gray-700 font-medium leading-relaxed mt-0.5">세현고는 청, 홍, 백 3개 팀이 학년별로 편성됩니다. 각 학년 경기에서 얻은 점수를 합산하여 최종 순위를 결정합니다.</p></div>;

      const gs = [...new Set(data.map(d => safeStr(d['학년'])))].filter(Boolean);

      gs.forEach(g => { html += <h3 class="text-lg font-bold text-slate-700 mb-4 mt-6 ml-1">${g} 팀 구성</h3><div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 mb-8">; data.filter(d => safeStr(d['학년']) === g).forEach(t => { let s = getTeamStyle(t['팀명']); html += <div class="${s.bg} border-2 ${s.border} rounded-2xl p-5 shadow-sm card-hover"><div class="flex items-center mb-3"><span class="text-2xl mr-2">${s.icon}</span><h4 class="text-xl font-extrabold ${s.text}">${safeStr(t['팀명'])}</h4></div><p class="text-gray-600 font-medium ml-1 text-sm">${safeStr(t['반'])}</p></div>; }); html += </div>; }); return html;

    }


    function renderGeneralTable(data) {

      let html = '<div class="grid gap-4 sm:grid-cols-2">'; data.forEach(item => { let es = Object.entries(item); if(es.length === 0) return; html += <div class="bg-white p-5 rounded-2xl border border-gray-100 shadow-sm card-hover"><h3 class="font-bold text-lg text-slate-800 mb-3 pb-2 border-b border-gray-100">${safeStr(es[0][1])}</h3><div class="space-y-2">; es.slice(1).map(([k, v]) => { if(v && String(v).trim() !== "") html += <div class="flex flex-col sm:flex-row sm:items-baseline"><span class="text-xs font-bold text-slate-400 w-20 flex-shrink-0 mb-1 sm:mb-0">${k}</span><span class="text-sm text-gray-700 font-medium">${String(v).replace(/\n/g, '<br>')}</span></div>; }); html += </div></div>; }); return html + '</div>';

    }

  </script>

</body>

</html>


PE-IS — 체육 교사 통합 수업 관리 플랫폼 · 로그인하면 글쓰기·댓글 등 더 많은 기능을 이용할 수 있습니다.