# 구글 시트 업무 기록을 매주 보고서로 자동 정리하기

> 완성된 시트를 내 Drive에 복사하고 시작하세요. 업무를 한 줄씩 기록하면 매주 월요일, 지난주 진행 상태와 담당자별 현황이 보고서 탭으로 정리됩니다.

- 2026-09-02 · 튜토리얼 · 초급 · 처음 설정 약 5분 · Google Sheets · Google Apps Script
- 원문: https://www.aihoon.kr/tutorials/sheets-weekly-report-claude

## 완성하면

업무기록에 적은 지난주 월~일의 업무를 집계해 같은 파일에 주간보고_날짜 탭을 만듭니다. 전체·완료·진행·보류 건수와 담당자별 현황, 상태별 업무 목록을 정리합니다.

## 시작하기 전에 · 사용 도구

- PC 브라우저와 개인 Google 계정으로 시작합니다. 회사·학교 계정은 관리자 정책에 따라 실행이 제한될 수 있습니다.
- 코드와 예시가 들어 있는 시트를 제공합니다. 기본 사용에 AI 대화, 코드 붙여넣기, API 키가 필요 없습니다. 자동 정리는 한국 시간 매주 월요일 오전 9~10시 사이입니다.

## 단계별 따라하기

### 1단계. 완성된 시트를 내 Drive에 복사하기

**이번에 할 일**

내 Google Drive에 사본을 만듭니다.

**확인할 결과**

- 수정할 수 있는 내 사본에서 시작 안내·업무기록·예시기록 탭이 보입니다.

**상세 참고**

아래 버튼을 눌러 Google 복사 화면에서 「사본 만들기」를 선택하세요. 내 Drive에 주간 업무 보고서 파일이 생깁니다. **연결된 자동화 코드도 함께 복사됩니다.**

파일에는 `시작 안내`, `업무기록`, `예시기록` 탭이 있습니다. 실제 업무를 적을 `업무기록`은 비워 두었습니다. 처음 동작을 살펴볼 자료는 `예시기록`에 따로 들어 있습니다.

공개 원본은 읽기 전용입니다. 다음 작업은 내가 복사한 파일에서 진행하세요. Apps Script 편집기를 열거나 빈 프로젝트를 만들 필요는 없습니다.

### 2단계. 시작하기를 누르고 예시 결과 확인하기

**이번에 할 일**

주간 보고서 → 시작하기를 실행하고 Google 권한을 승인합니다.

**확인할 결과**

- 예시보고서에 전체 6건, 완료 3건, 진행 2건, 보류 1건이 표시됩니다.
- 시작 안내의 현재 상태가 자동 정리 중으로 바뀝니다.

**상세 참고**

시트 위쪽 「주간 보고서」 → 「시작하기」를 누릅니다. 처음에는 Google 권한 승인이 나옵니다. 복사한 시트를 관리하고, 컴퓨터를 꺼도 예약 실행할 수 있게 연결하는 과정입니다.

승인 화면의 계정과 접근 범위를 확인하세요. 이 기능은 해당 시트 관리·예약 실행·실행 계정 확인을 사용하며, Gmail이나 캘린더 접근을 요청하지 않습니다. “Google에서 확인하지 않은 앱” 안내가 나올 수 있습니다. 본인이 복사한 스크립트의 개발자 계정을 확인하고, 모르는 계정이나 예상 밖의 권한이 보이면 진행하지 마세요.

연결되면 **예시보고서가 열리고 매주 자동 정리가 설정됩니다.** 승인 후 시트로 돌아왔는데 결과가 없다면 「시작하기」를 한 번 더 누르세요. 메뉴가 안 보이면 PC에서 사본을 새로고침하세요.

예시 8줄 중 지난주에 해당하는 6건만 집계합니다. 완료 3건·진행 2건·보류 1건이고, 기간 밖 2건은 빠집니다. 아래는 이 파일을 실제로 실행해 만든 화면입니다. 예시의 날짜는 사용하는 주에 맞춰 바뀝니다.

![복사본의 주간 보고서 메뉴에서 시작하기를 누릅니다.](<https://www.aihoon.kr/course-files/weekly-sheet-report/start-menu.png>)

![실제 실행 결과: 예시기록 8줄 중 지난주 6건만 보고서에 포함됩니다.](<https://www.aihoon.kr/course-files/weekly-sheet-report/example-report.png>)

### 3단계. 업무기록에 내 업무를 한 줄씩 적기

**이번에 할 일**

업무기록 탭에 실제 업무를 입력합니다.

**확인할 결과**

- 날짜·업무·담당·상태가 채워졌고 메모는 필요할 때만 적었습니다.

**상세 참고**

아래 탭에서 「업무기록」을 누릅니다. **날짜·업무·담당·상태** 네 항목을 채우고, 필요한 내용만 메모에 적으세요. 상태는 드롭다운에서 「완료」, 「진행」, 「보류」 중 하나를 고릅니다.

날짜는 `2026-09-21`처럼 연도까지 적거나 날짜 선택기를 사용하세요. 업무에는 “고객 문의 정리”, 담당에는 내 이름이나 담당자 이름을 쓰면 됩니다. 혼자 사용하는 경우 담당 칸에 같은 이름을 계속 써도 됩니다.

한 줄을 한 건으로 집계합니다. 하나의 업무를 매일 새 줄에 반복하면 여러 건이 됩니다. 같은 업무의 진행 상태를 바꾸려면 기존 줄의 상태를 수정하세요. 머리글 다섯 개와 탭 이름은 유지합니다.

아래 화면에는 동작 확인을 위해 작성한 가상 업무 4건이 들어 있습니다. 독자가 복사하는 원본의 업무기록은 빈 상태입니다. `예시기록`의 자료는 이 탭이나 실제 보고서에 자동으로 섞이지 않습니다.

![사용 예시: 업무 4줄을 입력했습니다. 날짜에 따라 지난주 보고서에 들어갈 기록이 결정됩니다.](<https://www.aihoon.kr/course-files/weekly-sheet-report/work-log.png>)

### 4단계. 지난주 보고서를 확인하기

**이번에 할 일**

지난주 보고서 만들기를 누르거나 다음 월요일 자동 실행 결과를 확인합니다.

**확인할 결과**

- 보고서 상단 기간이 내가 확인하려는 지난주와 일치합니다.
- 해당 기간의 업무만 상태·담당자별로 집계되고, 업무기록 원본은 유지됩니다.

**상세 참고**

매주 월요일 오전 9~10시 사이에 지난주 월요일부터 일요일까지의 기록을 모아 `주간보고_YYYY-MM-DD` 탭을 만듭니다. 탭 이름의 날짜는 **집계 대상 주의 월요일**입니다. 파일이나 컴퓨터를 열어 둘 필요는 없습니다.

바로 확인하려면 「주간 보고서」 → 「지난주 보고서 만들기」를 누르세요. 언제 누르든 지난주를 집계하므로, 이번 주 날짜로 적은 업무는 다음 월요일 보고서에 들어갑니다. 0건이면 보고서 상단의 집계 기간과 입력 날짜를 비교하세요.

보고서에는 전체·상태별 건수, 담당자별 건수, 완료·진행·보류 업무 목록이 들어 있습니다. 아래는 앞 단계의 가상 업무를 실제로 집계한 결과입니다. 4줄 중 기간에 맞는 3건만 포함되고, 별도 예시 6건은 들어오지 않습니다.

같은 주를 다시 만들면 기존 보고서를 현재 원본 값으로 갱신합니다. 원본 업무기록은 그대로 유지합니다. **보고서 탭에 직접 덧붙인 내용은 갱신할 때 지워지므로** 추가 의견은 업무기록의 메모나 별도 탭에 남기세요. 다음 주에는 날짜가 다른 새 보고서 탭이 생깁니다.

집계하는 것은 해당 기간 날짜로 적힌 기록의 현재 상태입니다. “언제 완료했는지”, “이번 주 성과가 좋은지”를 추정하는 기능은 아닙니다.

![실제 업무기록 집계 결과: 전체 3건 · 완료 2건 · 진행 1건. 기간 밖 1건과 예시기록은 제외됩니다.](<https://www.aihoon.kr/course-files/weekly-sheet-report/report-result.png>)

![보고서 아래에는 완료·진행·보류 업무가 나뉘어 표시됩니다. 원본의 메모도 함께 옮겨집니다.](<https://www.aihoon.kr/course-files/weekly-sheet-report/report-detail.png>)

### 5단계. 실행 상태를 확인하거나 자동 정리 멈추기

**이번에 할 일**

필요할 때 상태 확인이나 자동 정리 중지를 사용합니다.

**확인할 결과**

- 중지 후에는 자동 정리 꺼짐이 표시되고 기존 업무와 보고서는 남습니다.

**상세 참고**

「시작 안내」 탭에서 현재 상태와 최근 자동 실행을 확인할 수 있습니다. 「주간 보고서」 → 「상태 확인」을 누르면 최신 상태를 표시합니다. 예약을 설정한 것과 실제 예약 시간이 되어 보고서가 만들어진 것은 다릅니다.

자동 정리를 그만하려면 「자동 정리 중지」를 누르세요. 업무기록과 이미 만든 보고서는 남고, 다음 주 자동 생성만 멈춥니다. 「시작하기」를 다시 누르면 재개됩니다.

날짜·담당·상태가 비어 있거나 잘못된 값이 있으면 어느 행을 확인할지 알려 줍니다. 잘못된 입력을 수정한 뒤 「지난주 보고서 만들기」로 다시 실행하세요. 자동 실행에서 생긴 오류는 시작 안내의 「확인할 내용」에 표시됩니다.

기본 기능을 넘어 담당자별 완료율이나 다른 집계 항목을 넣고 싶다면, 아래 AI 도움에서 강좌와 전체 코드를 함께 복사해 원하는 변경을 요청하세요. 아래 코드는 참고용이며 기본 사용에는 직접 붙여넣을 필요가 없습니다.

**Code.gs 전체 코드**

```
// 구글 시트 업무 기록 → 지난주 보고서. 복사한 시트의 '주간 보고서' 메뉴에서 시작하세요.
/** @OnlyCurrentDoc */
var WEEKLY_CONFIG = {
  sourceSheet: '업무기록',
  reportPrefix: '주간보고_',
  timeZone: 'Asia/Seoul'
};
var WEEKLY_MARKER = 'gilho_weekly_report_v1';
var WEEKLY_HEADERS = ['날짜', '업무', '담당', '상태', '메모'];
var WEEKLY_STATUSES = ['완료', '진행', '보류'];

// ① 처음에는 직접 실행합니다. 예약 이후에는 같은 함수가 매주 실행됩니다.
function createWeeklyReport() {
  return withWeeklyLock_(function () {
    var ss = weeklySpreadsheet_();
    return writeWeeklyReport_(ss, WEEKLY_CONFIG.sourceSheet, false);
  });
}

function writeWeeklyReport_(ss, sourceName, example) {
    var source = ss.getSheetByName(sourceName);
    if (!source) throw new Error('원본 탭 [' + WEEKLY_CONFIG.sourceSheet + ']이 없습니다. 탭 이름을 확인하세요.');
    var report = buildWeeklyReport_(source.getDataRange().getValues(), new Date(), WEEKLY_CONFIG.timeZone);
    var name = example ? '예시보고서' : WEEKLY_CONFIG.reportPrefix + report.start;
    if (name === source.getName()) throw new Error('보고서 탭 이름과 원본 탭 이름이 같습니다. reportPrefix를 바꾸세요.');
    var target = ss.getSheetByName(name);
    if (target && !target.getDeveloperMetadata().some(function (item) {
      return item.getKey() === WEEKLY_MARKER && item.getValue() === 'generated';
    })) throw new Error('[' + name + ']은 이 코드가 만든 보고서가 아닙니다. 덮어쓰지 않았습니다. 탭 이름을 바꾸세요.');
    if (!target) {
      target = ss.insertSheet(name);
      target.addDeveloperMetadata(WEEKLY_MARKER, 'generated');
    }
    // 입력 검증이 모두 끝난 뒤, 이 코드가 만든 결과 탭만 갱신합니다.
    var matrix = weeklyReportRows_(report);
    target.getRange(1, 1, 3, 5).breakApart();
    target.clearContents();
    target.getRange(1, 1, matrix.length, 5).setValues(matrix).setWrap(true).setVerticalAlignment('top');
    target.getRange(1, 1, 1, 5).setFontWeight('bold');
    target.setFrozenRows(3);
    target.setColumnWidth(1, 130);
    target.setColumnWidth(2, 280);
    target.setColumnWidth(3, 120);
    target.setColumnWidth(4, 100);
    target.setColumnWidth(5, 380);
    formatWeeklyReport_(target, matrix, example);
    SpreadsheetApp.flush();
    console.log(name + ' 갱신 완료 · ' + report.start + ' ~ ' + report.end + ' · ' + report.records.length + '건');
    return report;
}

// ② 예시가 필요할 때만 한 번 실행하세요. 원본 탭에 값/수식이 하나라도 있으면 중단합니다.
function seedSampleData() {
  return withWeeklyLock_(function () {
    var ss = weeklySpreadsheet_();
    var source = ss.getSheetByName(WEEKLY_CONFIG.sourceSheet);
    if (source && source.getLastRow() > 0) throw new Error('[' + WEEKLY_CONFIG.sourceSheet + ']에 내용이 있어 예시를 넣지 않았습니다. 새 빈 실습 파일에서 실행하세요.');
    var period = previousWeek_(new Date(), WEEKLY_CONFIG.timeZone);
    var sample = [
      [shiftDateKey_(period.start, -1), '지난주보다 이전 기록', '길호', '완료', '보고서에서 제외되어야 합니다'],
      [period.start, '고객 안내문 수정', '길호', '완료', '새 요금 안내 반영'],
      [shiftDateKey_(period.start, 1), '소개 영상 편집', '길호', '진행', '자막 검수 중'],
      [shiftDateKey_(period.start, 2), '랜딩페이지 수정', '길호', '완료', '모바일 문구 정리'],
      [shiftDateKey_(period.start, 3), '상세페이지 제작', '민수', '진행', '제품 사진 대기'],
      [shiftDateKey_(period.start, 4), '견적서 발송', '민수', '완료', 'A사 견적 전달'],
      [period.end, '뉴스레터 발행', '길호', '보류', '승인 대기'],
      [shiftDateKey_(period.end, 1), '이번 주 기록', '민수', '진행', '보고서에서 제외되어야 합니다']
    ];
    if (!source) source = ss.insertSheet(WEEKLY_CONFIG.sourceSheet);
    var rows = [WEEKLY_HEADERS.slice()].concat(sample);
    source.getRange(1, 1, rows.length, 5).setValues(rows).setWrap(true);
    source.setFrozenRows(1);
    SpreadsheetApp.flush();
    console.log('예시 8줄 생성 완료. 지난주 보고서에는 6줄만 들어갑니다. 날짜는 생성 시점의 값으로 고정됩니다.');
  });
}

// ③ 수동 실행 결과를 확인한 뒤 한 번 실행하세요. 본인 계정의 이 함수 예약만 교체합니다.
function installWeeklyTrigger() {
  return withWeeklyLock_(function () {
    var ss = weeklySpreadsheet_();
    var source = ss.getSheetByName(WEEKLY_CONFIG.sourceSheet);
    if (!source) throw new Error('업무기록 탭을 먼저 준비하세요.');
    buildWeeklyReport_(source.getDataRange().getValues(), new Date(), WEEKLY_CONFIG.timeZone);
    // 설치가 실패했을 때 기존 예약까지 없어지지 않도록 새 예약을 만든 뒤 이전 것을 지웁니다.
    var previous = weeklyTriggers_();
    ScriptApp.newTrigger('createWeeklyReport').timeBased().everyWeeks(1)
      .onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).inTimezone(WEEKLY_CONFIG.timeZone).create();
    previous.forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); });
    console.log('매주 월요일 오전 9~10시(서울) 자동 정리를 설정했습니다. 정확히 9시 정각 실행은 아닙니다.');
  });
}

// ④ 자동 실행을 그만둘 때 실행하세요. 원본과 보고서는 지우지 않습니다.
function removeWeeklyTrigger() {
  return withWeeklyLock_(function () {
    var triggers = weeklyTriggers_();
    triggers.forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); });
    console.log('본인 계정의 주간 보고서 예약 ' + triggers.length + '개를 해제했습니다.');
  });
}

function weeklyTriggers_() {
  return ScriptApp.getProjectTriggers().filter(function (trigger) {
    return ['createWeeklyReport', 'runWeeklySchedule'].indexOf(trigger.getHandlerFunction()) !== -1 && trigger.getEventType() === ScriptApp.EventType.CLOCK;
  });
}

function formatWeeklyReport_(sheet, rows, example) {
  var body = sheet.getRange(1, 1, Math.max(rows.length, 35), 5);
  body.setBackground('#ffffff').setFontColor('#292622').setFontFamily('Arial').setFontSize(11);
  sheet.setHiddenGridlines(true);
  sheet.setRowHeights(1, Math.max(rows.length, 35), 30);
  sheet.getRange(1, 1, 1, 5).setBackground('#f8eee7').setFontSize(16).setFontWeight('bold');
  sheet.getRange(1, 1, 1, 5).merge();
  sheet.getRange(2, 2, 1, 4).merge();
  sheet.getRange(3, 2, 1, 4).merge();
  sheet.setRowHeight(1, 46);
  if (example) sheet.getRange(1, 1).setValue('예시 주간 보고서');
  sheet.setRowHeight(3, 46);
  sheet.getRange(3, 1, 1, 5).setFontSize(10).setFontColor('#777168');
  sheet.getRange(5, 1, 4, 2).setBackground('#faf7f2');
  sheet.getRange(5, 2, 4, 1).setFontWeight('bold').setFontSize(15);
  rows.forEach(function (row, i) {
    if (row[0] === '담당' || row[0] === '날짜') {
      sheet.getRange(i + 1, 1, 1, 5).setBackground('#3c443c').setFontColor('#ffffff').setFontWeight('bold');
    } else if (row[0] === '담당자별 기록' || WEEKLY_STATUSES.some(function (s) { return row[0] === s + ' 기록'; })) {
      sheet.getRange(i + 1, 1, 1, 5).setBackground('#eeeae3').setFontWeight('bold');
    }
  });
}

// 기본 사용 흐름: 파일 복사 → 메뉴에서 시작하기. 예시기록은 실제 업무와 별도입니다.
function onOpen() {
  SpreadsheetApp.getUi().createMenu('주간 보고서')
    .addItem('시작하기', 'startWeeklyReport')
    .addItem('지난주 보고서 만들기', 'makeWeeklyReportNow')
    .addItem('예시 보고서 보기', 'showWeeklyExample')
    .addSeparator().addItem('자동 정리 중지', 'stopWeeklyReport')
    .addItem('상태 확인', 'showWeeklyStatus').addToUi();
}

function startWeeklyReport() { return weeklyMenu_('start'); }
function makeWeeklyReportNow() { return weeklyMenu_('report'); }
function showWeeklyExample() { return weeklyMenu_('example'); }
function stopWeeklyReport() { return weeklyMenu_('stop'); }
function showWeeklyStatus() { return weeklyMenu_('status'); }

function weeklyMenu_(action) {
  if (typeof ScriptApp.requireScopes === 'function') ScriptApp.requireScopes(ScriptApp.AuthMode.FULL, [
    'https://www.googleapis.com/auth/spreadsheets.currentonly',
    'https://www.googleapis.com/auth/script.scriptapp',
    'https://www.googleapis.com/auth/userinfo.email'
  ]);
  var result;
  try {
    result = withWeeklyLock_(function () {
      var ss = weeklySpreadsheet_();
      var props = PropertiesService.getScriptProperties();
      bindWeeklyCopy_(ss, props);
      var message = '';
      if (action === 'start') {
        // 예시와 실제 입력을 모두 검증한 뒤 예약을 만듭니다.
        writeWeeklyReport_(ss, '예시기록', true);
        var source = ss.getSheetByName(WEEKLY_CONFIG.sourceSheet);
        if (!source) throw new Error('업무기록 탭이 없습니다. 원본 파일을 다시 복사해 주세요.');
        buildWeeklyReport_(source.getDataRange().getValues(), new Date(), WEEKLY_CONFIG.timeZone);
        if (props.getProperty('WEEKLY_ENABLED') !== 'true' || weeklyTriggers_().length !== 1) {
          var previous = weeklyTriggers_();
          ScriptApp.newTrigger('runWeeklySchedule').timeBased().everyWeeks(1)
            .onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).inTimezone(WEEKLY_CONFIG.timeZone).create();
          previous.forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); });
        }
        props.setProperty('WEEKLY_OWNER', Session.getEffectiveUser().getEmail().toLowerCase());
        props.setProperty('WEEKLY_ENABLED', 'true');
        ss.setActiveSheet(ss.getSheetByName('예시보고서'));
        message = '시작했습니다. 예시 보고서가 열렸어요.\n\n이제 업무기록 탭에 내 업무를 적으세요. 매주 월요일 오전 9~10시(한국 시간)에 지난주 보고서가 만들어집니다. 예시기록은 실제 보고서에 섞이지 않습니다.';
      } else if (action === 'report') {
        var report = writeWeeklyReport_(ss, WEEKLY_CONFIG.sourceSheet, false);
        props.setProperty('WEEKLY_LAST_MANUAL', weeklyTime_());
        ss.setActiveSheet(ss.getSheetByName(WEEKLY_CONFIG.reportPrefix + report.start));
        message = report.start + ' ~ ' + report.end + '\n' + report.records.length + '건을 정리했습니다. 지난주 밖의 날짜는 제외합니다.\n원본 업무기록은 그대로입니다.';
      } else if (action === 'example') {
        writeWeeklyReport_(ss, '예시기록', true);
        ss.setActiveSheet(ss.getSheetByName('예시보고서'));
        message = '예시기록만 집계한 보고서입니다. 실제 업무와 자동 정리 설정에는 영향을 주지 않습니다.';
      } else if (action === 'stop') {
        props.setProperty('WEEKLY_ENABLED', 'false');
        weeklyTriggers_().forEach(function (trigger) { ScriptApp.deleteTrigger(trigger); });
        message = '자동 정리를 중지했습니다. 업무기록과 기존 보고서는 그대로 남습니다. 다시 켜려면 시작하기를 누르세요.';
      }
      if (action === 'report' || action === 'start') props.deleteProperty('WEEKLY_ERROR');
      var status = updateWeeklyStatus_(ss, props);
      return { ok: true, message: message || status };
    });
  } catch (error) { result = { ok: false, message: error.message || String(error) }; }
  SpreadsheetApp.getUi().alert(result.ok ? '주간 보고서' : '입력을 확인해 주세요', result.message, SpreadsheetApp.getUi().ButtonSet.OK);
  return result;
}

function bindWeeklyCopy_(ss, props) {
  if (props.getProperty('WEEKLY_FILE') !== ss.getId()) {
    ['WEEKLY_OWNER', 'WEEKLY_ENABLED', 'WEEKLY_LAST_MANUAL', 'WEEKLY_LAST_AUTO', 'WEEKLY_ERROR'].forEach(function (key) { props.deleteProperty(key); });
    props.setProperty('WEEKLY_FILE', ss.getId());
  }
  var email = String(Session.getEffectiveUser().getEmail() || '').toLowerCase();
  if (!email) throw new Error('실행 계정을 확인하지 못했습니다. 개인 Google 계정으로 내 사본을 열어 주세요.');
  var owner = props.getProperty('WEEKLY_OWNER');
  if (owner && owner !== email) throw new Error('이 파일은 다른 계정이 시작했습니다. 각자 자신의 Drive에 복사해서 사용하세요.');
}

// 시간 기반 예약도 사용자 메뉴와 동일한 집계 함수를 실행합니다.
function runWeeklySchedule() {
  return withWeeklyLock_(function () {
    var ss = weeklySpreadsheet_();
    var props = PropertiesService.getScriptProperties();
    bindWeeklyCopy_(ss, props);
    if (props.getProperty('WEEKLY_ENABLED') !== 'true') return;
    try {
      var report = writeWeeklyReport_(ss, WEEKLY_CONFIG.sourceSheet, false);
      props.setProperty('WEEKLY_LAST_AUTO', weeklyTime_());
      props.deleteProperty('WEEKLY_ERROR');
      updateWeeklyStatus_(ss, props);
      return report;
    } catch (error) {
      props.setProperty('WEEKLY_ERROR', error.message || String(error));
      updateWeeklyStatus_(ss, props);
      throw error;
    }
  });
}

function weeklyTime_() { return Utilities.formatDate(new Date(), WEEKLY_CONFIG.timeZone, 'yyyy-MM-dd HH:mm'); }

function updateWeeklyStatus_(ss, props) {
  var enabled = props.getProperty('WEEKLY_ENABLED') === 'true';
  var status = enabled ? (weeklyTriggers_().length ? '자동 정리 중' : '예약이 없습니다. 시작하기를 다시 눌러 주세요.') : '자동 정리 꺼짐';
  var sheet = ss.getSheetByName('시작 안내');
  if (sheet) {
    sheet.getRange('B5').setValue(status);
    sheet.getRange('B7').setValue(props.getProperty('WEEKLY_LAST_AUTO') || '아직 자동 실행 전');
    sheet.getRange('B8').setValue(props.getProperty('WEEKLY_LAST_MANUAL') || '아직 직접 실행 전');
    sheet.getRange('B9').setValue(props.getProperty('WEEKLY_ERROR') || '없음');
  }
  return status + '\n매주 월요일 오전 9~10시(한국 시간)\n최근 자동 실행: ' + (props.getProperty('WEEKLY_LAST_AUTO') || '아직 없음') + (props.getProperty('WEEKLY_ERROR') ? '\n확인할 내용: ' + props.getProperty('WEEKLY_ERROR') : '');
}

function withWeeklyLock_(run) {
  var lock = LockService.getDocumentLock();
  if (!lock) throw new Error('구글 시트의 확장 프로그램 > Apps Script에서 만든 프로젝트를 사용하세요.');
  if (!lock.tryLock(10000)) throw new Error('다른 정리 작업이 실행 중입니다. 잠시 뒤 다시 실행하세요.');
  try { return run(); } finally { lock.releaseLock(); }
}

function weeklySpreadsheet_() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  if (!ss) throw new Error('구글 시트에 연결된 Apps Script 프로젝트가 아닙니다.');
  if (ss.getSpreadsheetTimeZone() !== WEEKLY_CONFIG.timeZone || Session.getScriptTimeZone() !== WEEKLY_CONFIG.timeZone) {
    throw new Error('시트의 파일 > 설정과 Apps Script의 프로젝트 설정에서 시간대를 모두 서울(Asia/Seoul)로 맞추세요.');
  }
  return ss;
}

function previousWeek_(now, timeZone) {
  var today = Utilities.formatDate(now, timeZone, 'yyyy-MM-dd');
  var utc = Date.parse(today + 'T00:00:00Z');
  var daySinceMonday = (new Date(utc).getUTCDay() + 6) % 7;
  var start = shiftDateKey_(today, -daySinceMonday - 7);
  return { start: start, end: shiftDateKey_(start, 6) };
}

function shiftDateKey_(key, days) {
  return Utilities.formatDate(new Date(Date.parse(key + 'T00:00:00Z') + days * 86400000), 'UTC', 'yyyy-MM-dd');
}

function weeklyDateKey_(value, timeZone) {
  if (Object.prototype.toString.call(value) === '[object Date]' && !isNaN(value.getTime())) {
    return Utilities.formatDate(value, timeZone, 'yyyy-MM-dd');
  }
  var key = typeof value === 'string' ? value.trim() : '';
  if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return null;
  var stamp = Date.parse(key + 'T00:00:00Z');
  if (isNaN(stamp)) return null;
  return Utilities.formatDate(new Date(stamp), 'UTC', 'yyyy-MM-dd') === key ? key : null;
}

// 시트에 쓰기 전의 집계 단계입니다. 잘못된 행이 있으면 기존 보고서를 바꾸기 전에 멈춥니다.
function buildWeeklyReport_(values, now, timeZone) {
  var headers = (values[0] || []).map(function (value) { return String(value).trim(); });
  var columns = {};
  WEEKLY_HEADERS.forEach(function (name) {
    var indexes = [];
    headers.forEach(function (header, index) { if (header === name) indexes.push(index); });
    if (indexes.length !== 1) throw new Error('1행의 머리글 [' + name + ']이 없거나 중복되었습니다. 한 개씩 입력하세요: ' + WEEKLY_HEADERS.join(' / '));
    columns[name] = indexes[0];
  });
  var period = previousWeek_(now, timeZone);
  var records = [];
  var errors = [];
  values.slice(1).forEach(function (row, index) {
    if (row.every(function (cell) { return String(cell).trim() === ''; })) return;
    var number = index + 2;
    var date = weeklyDateKey_(row[columns['날짜']], timeZone);
    var task = row[columns['업무']];
    var owner = row[columns['담당']];
    var status = String(row[columns['상태']] || '').trim();
    if (!date) errors.push(number + '행: 날짜를 실제 날짜 또는 YYYY-MM-DD로 입력하세요.');
    if (typeof task !== 'string' || !task.trim()) errors.push(number + '행: 업무 이름을 글자로 입력하세요.');
    if (typeof owner !== 'string' || !owner.trim()) errors.push(number + '행: 담당 이름을 글자로 입력하세요.');
    if (WEEKLY_STATUSES.indexOf(status) === -1) errors.push(number + '행: 상태는 완료 / 진행 / 보류 중 하나여야 합니다.');
    if (date && date >= period.start && date <= period.end) {
      records.push({ date: date, task: String(task).trim(), owner: String(owner).trim(), status: status, memo: String(row[columns['메모']] == null ? '' : row[columns['메모']]).trim(), row: number });
    }
  });
  if (errors.length) throw new Error('업무기록을 수정한 뒤 다시 실행하세요. 보고서는 바꾸지 않았습니다.\n' + errors.slice(0, 12).join('\n') + (errors.length > 12 ? '\n외 ' + (errors.length - 12) + '개 오류' : ''));
  records.sort(function (a, b) { return a.date.localeCompare(b.date) || a.row - b.row; });
  var counts = { '완료': 0, '진행': 0, '보류': 0 };
  var byOwner = Object.create(null);
  records.forEach(function (record) {
    counts[record.status] += 1;
    if (!byOwner[record.owner]) byOwner[record.owner] = { total: 0, '완료': 0, '진행': 0, '보류': 0 };
    byOwner[record.owner].total += 1;
    byOwner[record.owner][record.status] += 1;
  });
  return { start: period.start, end: period.end, records: records, counts: counts, byOwner: byOwner };
}

function weeklyReportRows_(report) {
  var rows = [
    ['주간 업무 보고서'], ['집계 기간', report.start + ' ~ ' + report.end],
    ['기준', '해당 기간 날짜의 업무 기록 · 상태는 마지막 실행 시점 기준'], [],
    ['전체 기록', report.records.length], ['완료', report.counts['완료']],
    ['진행', report.counts['진행']], ['보류', report.counts['보류']], [],
    ['담당자별 기록'], ['담당', '전체', '완료', '진행', '보류']
  ];
  Object.keys(report.byOwner).sort().forEach(function (owner) {
    var counts = report.byOwner[owner];
    rows.push([owner, counts.total, counts['완료'], counts['진행'], counts['보류']]);
  });
  if (!report.records.length) rows.push(['해당 기간 기록이 없습니다.']);
  WEEKLY_STATUSES.forEach(function (status) {
    rows.push([], [status + ' 기록'], WEEKLY_HEADERS.slice());
    var matches = report.records.filter(function (record) { return record.status === status; });
    matches.forEach(function (record) { rows.push([record.date, record.task, record.owner, record.status, record.memo]); });
    if (!matches.length) rows.push(['없음']);
  });
  return rows.map(function (row) {
    var padded = row.slice();
    while (padded.length < 5) padded.push('');
    return padded.map(function (cell) {
      // 메모의 '=...'를 보고서에서 새로운 수식으로 실행하지 않습니다.
      return typeof cell === 'string' && /^\s*=/.test(cell) ? "'" + cell : cell;
    });
  });
}

```

_복사한 시트에 이미 들어 있습니다. 기능 변경·문제 해결 때만 참고하세요._

## 자주 묻는 질문

**Q. 내가 쓰던 구글 시트를 그대로 연결할 수 있나요?**

기본 파일은 함께 제공한 업무기록 탭만 읽습니다. 기존 자료 중 날짜·업무·담당·상태·메모를 맞춰 이 탭으로 가져와 사용할 수 있습니다. 다른 파일이나 다른 열 구조를 직접 연결하려면 아래 AI 도움으로 별도 수정을 요청하세요.

**Q. 기록을 넣었는데 보고서가 0건이에요.**

지난주 월요일부터 일요일까지의 날짜만 집계합니다. 이번 주 기록은 다음 월요일부터 포함됩니다. 실제 데이터는 업무기록 탭에 적고, 예시보고서가 아닌 주간보고_날짜 탭을 확인하세요.

**Q. 월요일인데 보고서가 아직 없어요.**

한국 시간 오전 9~10시 사이에 실행됩니다. 시작 안내에서 자동 정리 상태와 최근 자동 실행·확인할 내용을 보세요. 10시 이후에도 없으면 상태 확인을 누른 뒤 표시된 문구로 도움을 요청하세요. Google 사용량 제한이나 계정 정책에 따라 실행이 중단될 수 있습니다.

**Q. Google에서 확인하지 않은 앱이라고 나와요.**

본인이 복사한 Apps Script를 처음 실행할 때 나올 수 있습니다. 공개 앱 검증을 받은 프로그램은 아닙니다. 실행 계정과 권한 범위를 확인하고 승인하세요. 조직에서 차단한 경우에는 정책을 우회하지 말고 관리자에게 확인하거나 개인 계정의 사본에서 진행하세요.

**Q. 실제 업무가 길호에게 전달되나요?**

제공 코드는 복사한 시트 안에서 집계합니다. 홈페이지 운영자에게 기록을 전송하거나 외부 AI API로 보내지 않습니다. AI 도움 버튼도 강좌와 기본 코드만 복사하며, 내 시트의 데이터는 읽지 않습니다.

**Q. AI가 꼭 필요한 강좌인가요?**

기본 보고서는 Apps Script가 정해진 기준으로 만듭니다. AI 없이 바로 사용할 수 있고, 보고서 항목이나 동작을 내 업무에 맞게 바꾸고 싶을 때 AI를 활용하면 됩니다.

## 다운로드

- [주간 업무 보고서 · Google 시트 복사](<https://docs.google.com/spreadsheets/d/1-ijgwaikvAapwacCNGBJqAbZccvhR8J3i_9JyxGf65Q/copy>) — 자동화 코드·빈 업무기록·예시 포함
- [Code.gs 원본 코드](<https://www.aihoon.kr/course-files/weekly-sheet-report/Code.gs>) — 기능 변경·문제 해결용
- [권한과 시간대 설정](<https://www.aihoon.kr/course-files/weekly-sheet-report/appsscript.json>) — 직접 코드를 옮길 때 참고
- [사용 안내](<https://www.aihoon.kr/course-files/weekly-sheet-report/README.md>) — 복사·실행·입력·중지 안내

## 한 걸음 더

내 업무를 기록하며 한 주 동안 사용해 보세요. 매일 아침 오늘 일정을 이메일로 받고 싶다면 일정 메일 자동화 강좌로 이어갈 수 있습니다.
