Bahadır Eren

Claude'da yaptığın uygulamayı ekibine açmanın en kolay yolu: Google Apps Script

Claude'da bir uygulama yaptın ve çalışıyor. Ekip arkadaşına göstermek istediğin an sorular başlıyor: veriler nerede duracak, kim erişebilecek, bunu bir sunucuya kim kuracak? Şirketin Google Workspace kullanıyorsa bu soruların cevabı zaten elindeki araçta: Google Apps Script. Bu yazıda gerçek bir uygulamayı, bir ekip izin takvimini baştan sona kuruyoruz. Uygulama izin taleplerini toplayıp yöneticiye onaylatıyor, onaylanan izni Google Takvim'e davet olarak gönderiyor, izinden önceki gün Google Meet linkli bir devir görüşmesi kuruyor ve her sabah Gmail'den hatırlatma atıyor. Kodların tamamı yazının içinde, kopyalayıp kendi hesabında çalıştırabilirsin.

Neden Apps Script?

Claude artifact'leri paylaşılabiliyor, ama paylaşılan şey Claude'un içinde yaşıyor. Apps Script ise uygulamayı şirketinin kendi Google ortamına taşıyor. Fark dört noktada ortaya çıkıyor:

Sınırlarını da baştan söyleyeyim. Bazı şirketlerde BT, web uygulaması yayınlamayı kapatmış olabilir; başlamadan önce Workspace yöneticine sor. Mail gönderimi ve çalışma süresi için günlük kotalar var; güncel değerler için Google'ın kota sayfasına bak. Claude'un ürettiği React artifact'i Apps Script'te olduğu gibi çalışmaz, çevrilmesi gerekir; bunun için aşağıda hazır bir prompt var. Yüzlerce kullanıcılı, karmaşık yetki yapısı olan sistemler için de doğru araç değil. Ekip ölçeğindeki iç araçlar için ise şu an bildiğim en hızlı yol.

Örnek uygulama: Ekip İzin Takvimi

Uygulamanın akışı şöyle:

  1. Çalışan web uygulamasını açar, başlangıç ve bitiş tarihini seçer, iş günü sayısı kendiliğinden hesaplanır. Kim olduğunu yazmaz; uygulama onu Google oturumundan tanır.
  2. Talep E-Tabloya düşer, yöneticiye Gmail bildirimi gider.
  3. Yönetici onay ekranında aynı tarihlerde kimlerin izinli olduğunu görür, not ekleyip onaylar ya da reddeder.
  4. Onaylanan izin ekibin ortak Google Takvim'ine eklenir ve çalışana takvim daveti gider.
  5. İzinden önceki son iş günü saat 16:00'ya, yönetici ile çalışan arasında Google Meet linkli 30 dakikalık bir devir görüşmesi kurulur.
  6. Her sabah 9 ile 10 arasında çalışan bir tetikleyici, ertesi iş günü izne çıkacak kişiye hatırlatma, yöneticiye de "bugün kim izinde, hangi talepler bekliyor" özetini Gmail ile gönderir.

Takvim ekranında onaylı izinler yeşil, bekleyen talepler kesik çizgili görünür. Aynı gün izinli kişi sayısı belirlediğin sınırı aşarsa o gün kırmızı çizgiyle işaretlenir.

Önce bir dene: canlı demo

Aşağıdaki pencere uygulamanın birebir arayüzü, sadece veriler kurgusal ve hiçbir yere kaydedilmiyor. Sağ alttaki çubuktan Çalışan ya da Yönetici rolünü seç. Yönetici olarak "Onay bekleyenler" sekmesinden bir talebi onayla, sonra takvimde nasıl göründüğüne bak. Çalışan olarak yeni bir talep gir. Gerçek uygulamada onay anında takvim daveti, Meet linki ve Gmail bildirimi de gider; demoda bunlar sadece ekranda görünür.

Pencere telefonda dar geliyorsa demoyu tam ekranda aç.

Adım 1: Claude'da prototipi yap, sonra çevirt

Uygulamayı önce Claude'da normal şekilde tarif et ve artifact olarak çalıştır. Arayüz ve akış istediğin gibi olunca aşağıdaki prompt'u aynı sohbete yapıştır. Bu prompt, Claude'un Apps Script'te çalışmayan alışkanlıklarını (React, localStorage, harici API çağrıları) baştan kapatır:

Bu uygulamayı Google Apps Script web uygulamasına çevir. Kurallar:
- Üç dosya ver: Code.gs, Index.html ve appsscript.json.
- Veriler, script'in bağlı olduğu Google E-Tablo'da tutulsun.
- React kullanma. Arayüz tek bir HTML dosyasında düz JavaScript ile yazılsın.
- Tarayıcıdan sunucuya tüm çağrılar google.script.run ile yapılsın; fetch, localStorage veya harici API kullanma.
- Kullanıcı kimliği Session.getActiveUser() ile alınsın. Uygulama "Ben olarak çalıştır" ve "alan adındaki herkes" ayarıyla yayınlanacak.
- Tarihleri E-Tabloya yyyy-MM-dd biçiminde düz metin olarak yaz.
- Gerekli sayfaları ve ayarları oluşturan bir "kurulum" fonksiyonu ekle.
- Her dosyayı eksiksiz, tek blok halinde ver.

Claude'un ürettiği kodu ekibine açmadan önce mutlaka bir test hesabında dene.

Adım 2: E-Tabloyu ve Apps Script projesini aç

Google Drive'da boş bir E-Tablo oluştur, adını "Ekip İzin Takvimi" koy. Menüden Uzantılar > Apps Script'e tıkla. Açılan editör, bu E-Tabloya bağlı bir Apps Script projesidir. Proje adını da sol üstten değiştir.

Adım 3: Manifest dosyasını yapıştır (appsscript.json)

Sol menüden Proje Ayarları'na gir ve "appsscript.json manifest dosyasını düzenleyicide göster" kutusunu işaretle. Editöre dön, appsscript.json dosyasının içeriğini aşağıdakiyle değiştir. Bu dosya saat dilimini İstanbul yapar, Meet linki için gereken Google Calendar API gelişmiş hizmetini açar ve web uygulamasının varsayılan ayarlarını belirler.

{
  "timeZone": "Europe/Istanbul",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "Calendar",
        "serviceId": "calendar",
        "version": "v3"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "DOMAIN"
  }
}

Manifesti düzenlemek istemezsen aynı hizmeti sol menüde Hizmetler (+) > Google Calendar API > Ekle ile de açabilirsin; sonuç aynı.

Adım 4: Sunucu kodunu yapıştır (Code.gs)

Code.gs dosyasının içeriğini tamamen silip aşağıdaki kodu yapıştır. Yazıyla ilgili kısımlara bakmak istersen üç fonksiyona odaklan: karar onaylanan izni ekip takvimine ekleyip çalışana davet gönderir, devirGorusmesiKur_ Calendar API ile Meet linkli görüşmeyi kurar, sabahHatirlatmasi ise her sabah tetiklenip Gmail hatırlatmalarını gönderir.

/**
 * Ekip İzin Takvimi
 * Google Sheets + Google Takvim + Google Meet + Gmail ile çalışan Apps Script web uygulaması.
 *
 * Ne yapar:
 * - Çalışan izin talebi girer, yöneticiye Gmail bildirimi gider.
 * - Yönetici onaylayınca izin ekip takvimine eklenir ve çalışana takvim daveti gider.
 * - İzinden önceki son iş gününe, yönetici ile çalışan arasında Meet linkli devir görüşmesi kurulur.
 * - Her sabah çalışan tetikleyici, ertesi iş günü izne çıkacaklara ve yöneticiye özet hatırlatma gönderir.
 *
 * Gereken: Hizmetler (+) menüsünden "Google Calendar API" gelişmiş hizmetini eklemek.
 */

const SAYFA_TALEP = 'Talepler';
const SAYFA_AYAR = 'Ayarlar';
const BASLIKLAR = ['ID', 'Oluşturma', 'E-posta', 'Başlangıç', 'Bitiş', 'Tür', 'İş günü', 'Not', 'Durum', 'Karar veren', 'Karar tarihi', 'Yönetici notu', 'Takvim etkinlik ID', 'Devir görüşmesi ID', 'Meet linki'];
const IZIN_TURLERI = ['Yıllık izin', 'Mazeret izni', 'Raporlu', 'Ücretsiz izin'];
const DURUM = { BEKLIYOR: 'Bekliyor', ONAY: 'Onaylandı', RED: 'Reddedildi', IPTAL: 'İptal edildi' };
const TARIH_RE = /^\d{4}-\d{2}-\d{2}$/;
const AZAMI_IS_GUNU = 60;
const HATIRLATMA_SAATI = 9;
const BEKLEME_ESIGI_GUN = 2;

/* ---------- Tablo menüsü ve kurulum ---------- */

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('İzin Takvimi')
    .addItem('Kurulumu çalıştır', 'kurulum')
    .addItem('Sabah hatırlatmasını şimdi dene', 'sabahHatirlatmasi')
    .addToUi();
}

function kurulum() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  const talep = ss.getSheetByName(SAYFA_TALEP) || ss.insertSheet(SAYFA_TALEP);
  talep.getRange(1, 1, talep.getMaxRows(), BASLIKLAR.length).setNumberFormat('@');
  talep.getRange(1, 1, 1, BASLIKLAR.length).setValues([BASLIKLAR]).setFontWeight('bold');
  talep.setFrozenRows(1);

  const ayar = ss.getSheetByName(SAYFA_AYAR) || ss.insertSheet(SAYFA_AYAR);
  ayar.getRange('A1:B5').setNumberFormat('@');
  const varsayilanlar = [
    ['Ayar', 'Değer'],
    ['Yönetici e-posta (birden fazlaysa virgülle ayır)', Session.getEffectiveUser().getEmail()],
    ['Aynı gün en fazla izinli kişi', '2'],
    ['Devir görüşmesi saati (SS:DD)', '16:00'],
    ['Devir görüşmesi süresi (dakika)', '30']
  ];
  varsayilanlar.forEach((satir, i) => {
    if (!ayar.getRange(i + 1, 1).getValue()) ayar.getRange(i + 1, 1, 1, 2).setValues([satir]);
  });
  ayar.getRange('A1:B1').setFontWeight('bold');
  ayar.autoResizeColumn(1);

  const props = PropertiesService.getScriptProperties();
  let takvimId = props.getProperty('TAKVIM_ID');
  if (!takvimId || !CalendarApp.getCalendarById(takvimId)) {
    takvimId = CalendarApp.createCalendar('Ekip İzin Takvimi').getId();
  }
  props.setProperties({ SS_ID: ss.getId(), TAKVIM_ID: takvimId });

  ScriptApp.getProjectTriggers()
    .filter(tr => tr.getHandlerFunction() === 'sabahHatirlatmasi')
    .forEach(tr => ScriptApp.deleteTrigger(tr));
  ScriptApp.newTrigger('sabahHatirlatmasi').timeBased().everyDays(1).atHour(HATIRLATMA_SAATI).create();

  const meetHazir = typeof Calendar !== 'undefined';
  const mesaj = 'Kurulum tamamlandı.' + (meetHazir
    ? ' Şimdi web uygulaması olarak dağıtabilirsin.'
    : ' Uyarı: Google Calendar API hizmeti ekli değil, devir görüşmesi kurulamaz. Hizmetler (+) menüsünden ekle.');
  Logger.log(mesaj + ' Takvim ID: ' + takvimId);
  try { ss.toast(mesaj, 'İzin Takvimi', 10); } catch (e) {}
}

/* ---------- Web uygulaması ---------- */

function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index')
    .setTitle('Ekip İzin Takvimi')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

/** Sayfa açılışında tüm veriyi tek seferde döndürür. */
function baslangicVerisi() {
  const email = aktifKullanici_();
  const ayar = ayarlariOku_();
  const yonetici = ayar.yoneticiler.indexOf(email) !== -1;
  const tum = talepleriOku_();

  const disaAc = t => ({
    id: t.id, ad: adiBul_(t.email), baslangic: t.baslangic, bitis: t.bitis, tur: t.tur,
    gun: t.gun, not: t.not, durum: t.durum, yoneticiNotu: t.yoneticiNotu, olusturma: t.olusturma,
    meetLink: t.meetLink
  });

  return {
    email: email,
    ad: adiBul_(email),
    yonetici: yonetici,
    limit: ayar.limit,
    bugun: bugun_(),
    izinTurleri: IZIN_TURLERI,
    taleplerim: tum.filter(t => t.email === email).reverse().map(disaAc),
    bekleyenler: yonetici ? tum.filter(t => t.durum === DURUM.BEKLIYOR).map(disaAc) : [],
    takvim: tum
      .filter(t => t.durum === DURUM.ONAY || t.durum === DURUM.BEKLIYOR)
      .map(t => ({ ad: adiBul_(t.email), baslangic: t.baslangic, bitis: t.bitis, durum: t.durum }))
  };
}

/** Çalışanın yeni izin talebi. */
function talepGonder(form) {
  const email = aktifKullanici_();
  const ayar = ayarlariOku_();
  const bas = String(form.baslangic || '');
  const bit = String(form.bitis || '');
  const tur = String(form.tur || '');
  const not = String(form.not || '').trim().slice(0, 300);

  if (!TARIH_RE.test(bas) || !TARIH_RE.test(bit)) throw new Error('Başlangıç ve bitiş tarihini seç.');
  if (bit < bas) throw new Error('Bitiş tarihi başlangıçtan önce olamaz.');
  if (IZIN_TURLERI.indexOf(tur) === -1) throw new Error('Listeden bir izin türü seç.');

  const gunler = isGunleri_(bas, bit);
  if (!gunler.length) throw new Error('Seçilen aralıkta iş günü yok.');
  if (gunler.length > AZAMI_IS_GUNU) throw new Error('Tek talepte en fazla ' + AZAMI_IS_GUNU + ' iş günü girilebilir.');

  let yogunGunler = [];
  const kilit = LockService.getScriptLock();
  kilit.waitLock(10000);
  try {
    const aktif = talepleriOku_().filter(t => t.durum === DURUM.BEKLIYOR || t.durum === DURUM.ONAY);
    if (aktif.some(t => t.email === email && cakisir_(t, bas, bit))) {
      throw new Error('Bu tarihlerle çakışan bir talebin zaten var.');
    }
    yogunGunler = gunler.filter(g =>
      aktif.filter(t => t.email !== email && t.baslangic <= g && g <= t.bitis).length >= ayar.limit
    );
    satirEkle_([Utilities.getUuid().slice(0, 8), simdi_(), email, bas, bit, tur, String(gunler.length), not, DURUM.BEKLIYOR, '', '', '', '', '', '']);
  } finally {
    kilit.releaseLock();
  }

  const uyari = yogunGunler.length
    ? yogunGunler.length + ' gün için ekipte izin sınırı (' + ayar.limit + ' kişi) dolu. Yöneticin bunu görecek.'
    : '';

  bildir_(
    ayar.yoneticiler.join(','),
    'İzin talebi: ' + adiBul_(email) + ' (' + bas + ' / ' + bit + ')',
    adiBul_(email) + ', ' + gunler.length + ' iş günü ' + tur.toLowerCase() + ' talep etti.\n' +
    'Tarih: ' + bas + ' ile ' + bit + ' arası\n' +
    (not ? 'Not: ' + not + '\n' : '') +
    (uyari ? 'Uyarı: ' + uyari + '\n' : '') +
    '\nTalebi görmek ve sonuçlandırmak için: ' + uygulamaUrl_()
  );

  return { mesaj: 'Talebin yöneticine iletildi.', uyari: uyari };
}

/** Yöneticinin onay veya ret kararı. */
function karar(id, onay, yoneticiNotu) {
  const email = aktifKullanici_();
  const ayar = ayarlariOku_();
  if (ayar.yoneticiler.indexOf(email) === -1) throw new Error('Bu işlem için yönetici yetkisi gerekiyor.');

  const notMetni = String(yoneticiNotu || '').trim().slice(0, 300);
  let t;
  let devir = { id: '', link: '', zaman: '', uyari: '' };
  const kilit = LockService.getScriptLock();
  kilit.waitLock(10000);
  try {
    t = talepleriOku_().find(x => x.id === id);
    if (!t) throw new Error('Talep bulunamadı.');
    if (t.durum !== DURUM.BEKLIYOR) throw new Error('Bu talep zaten sonuçlanmış: ' + t.durum);

    let etkinlikId = '';
    if (onay) {
      // 1) Ekip takvimine tüm gün etkinliği + çalışana takvim daveti
      const etkinlik = takvim_().createAllDayEvent(
        adiBul_(t.email) + ' - ' + t.tur,
        tarih_(t.baslangic),
        gunEkle_(tarih_(t.bitis), 1),
        { guests: t.email, sendInvites: true, description: t.gun + ' iş günü. Ekip İzin Takvimi tarafından eklendi.' }
      );
      etkinlikId = etkinlik.getId();
      // 2) İzinden önceki iş günü Meet linkli devir görüşmesi
      devir = devirGorusmesiKur_(t, email, ayar);
    }
    talepSayfasi_()
      .getRange(t.satir, 9, 1, 7)
      .setNumberFormat('@')
      .setValues([[onay ? DURUM.ONAY : DURUM.RED, email, simdi_(), notMetni, etkinlikId, devir.id, devir.link]]);
  } finally {
    kilit.releaseLock();
  }

  bildir_(
    t.email,
    'İzin talebin ' + (onay ? 'onaylandı' : 'reddedildi') + ' (' + t.baslangic + ' / ' + t.bitis + ')',
    t.baslangic + ' ile ' + t.bitis + ' arasındaki ' + t.tur.toLowerCase() + ' talebin ' +
    (onay ? 'onaylandı ve ekip takvimine eklendi. Takvim davetini ayrıca aldın.' : 'reddedildi.') + '\n' +
    (notMetni ? 'Yönetici notu: ' + notMetni + '\n' : '') +
    (devir.link ? '\nDevir görüşmesi: ' + devir.zaman + '\nMeet linki: ' + devir.link + '\n' : '') +
    '\nUygulama: ' + uygulamaUrl_()
  );

  const mesaj = onay
    ? 'Talep onaylandı, takvime eklendi ve davet gönderildi.' + (devir.link ? ' Devir görüşmesi kuruldu.' : '')
    : 'Talep reddedildi.';
  return { mesaj: mesaj, uyari: devir.uyari };
}

/** Çalışanın kendi talebini iptal etmesi. */
function iptal(id) {
  const email = aktifKullanici_();
  let t;
  let onayliydi = false;
  const kilit = LockService.getScriptLock();
  kilit.waitLock(10000);
  try {
    t = talepleriOku_().find(x => x.id === id);
    if (!t || t.email !== email) throw new Error('Talep bulunamadı.');

    if (t.durum === DURUM.ONAY && t.baslangic > bugun_()) {
      onayliydi = true;
      if (t.etkinlikId) {
        try {
          const etkinlik = takvim_().getEventById(t.etkinlikId);
          if (etkinlik) etkinlik.deleteEvent();
        } catch (e) { Logger.log('Ekip takvimi etkinliği silinemedi: ' + e); }
      }
      if (t.devirId) {
        try {
          Calendar.Events.remove('primary', t.devirId, { sendUpdates: 'all' });
        } catch (e) { Logger.log('Devir görüşmesi silinemedi: ' + e); }
      }
    } else if (t.durum !== DURUM.BEKLIYOR) {
      throw new Error('Başlamış veya sonuçlanmış bir talep iptal edilemez.');
    }
    talepSayfasi_().getRange(t.satir, 9).setNumberFormat('@').setValue(DURUM.IPTAL);
  } finally {
    kilit.releaseLock();
  }

  if (onayliydi) {
    bildir_(
      ayarlariOku_().yoneticiler.join(','),
      'Onaylı izin iptal edildi: ' + adiBul_(email),
      adiBul_(email) + ', ' + t.baslangic + ' ile ' + t.bitis + ' arasındaki onaylı iznini iptal etti. ' +
      'Ekip takvimindeki etkinlik ve devir görüşmesi kaldırıldı.'
    );
  }
  return { mesaj: 'Talep iptal edildi.' };
}

/* ---------- Zamanlanmış hatırlatma (her sabah) ---------- */

function sabahHatirlatmasi() {
  const bugunTarih = tarih_(bugun_());
  const gun = bugunTarih.getDay();
  if (gun === 0 || gun === 6) return;

  const bugun = metin_(bugunTarih);
  const sonrakiIsGunu = metin_(sonrakiIsGunu_(bugunTarih));
  const esik = metin_(gunEkle_(bugunTarih, -BEKLEME_ESIGI_GUN));
  const ayar = ayarlariOku_();
  const tum = talepleriOku_();

  // 1) Bir sonraki iş günü izne çıkacak çalışanlara hatırlatma
  tum.filter(t => t.durum === DURUM.ONAY && t.baslangic === sonrakiIsGunu).forEach(t => {
    bildir_(
      t.email,
      'Hatırlatma: iznin ' + t.baslangic + ' tarihinde başlıyor',
      'Merhaba ' + adiBul_(t.email).split(' ')[0] + ',\n\n' +
      t.baslangic + ' ile ' + t.bitis + ' arasında izindesin. Çıkmadan önce:\n' +
      '- Gmail otomatik yanıtını aç.\n' +
      '- Açık işlerin devrini tamamla.\n' +
      (t.meetLink ? '- Devir görüşmesi bugün. Meet linki: ' + t.meetLink + '\n' : '') +
      '\nİyi dinlenmeler.'
    );
  });

  // 2) Yöneticiye günlük özet: bugün izinde olanlar ve uzun süredir bekleyen talepler
  const bugunIzinli = tum.filter(t => t.durum === DURUM.ONAY && t.baslangic <= bugun && bugun <= t.bitis);
  const bekleyen = tum.filter(t => t.durum === DURUM.BEKLIYOR && t.olusturma.slice(0, 10) <= esik);
  if (!bugunIzinli.length && !bekleyen.length) return;

  let govde = 'Ekip izin özeti, ' + bugun + '\n\n';
  if (bugunIzinli.length) {
    govde += 'Bugün izinde:\n' + bugunIzinli.map(t => '- ' + adiBul_(t.email) + ' (' + t.bitis + ' tarihine kadar)').join('\n') + '\n\n';
  }
  if (bekleyen.length) {
    govde += BEKLEME_ESIGI_GUN + ' günden uzun süredir onay bekleyen talepler:\n' +
      bekleyen.map(t => '- ' + adiBul_(t.email) + ': ' + t.baslangic + ' / ' + t.bitis).join('\n') + '\n\n';
  }
  govde += 'Uygulama: ' + uygulamaUrl_();
  bildir_(ayar.yoneticiler.join(','), 'Ekip izin özeti: ' + bugun, govde);
}

/* ---------- Devir görüşmesi (Google Calendar API + Meet) ---------- */

function devirGorusmesiKur_(t, yoneticiEmail, ayar) {
  const sonuc = { id: '', link: '', zaman: '', uyari: '' };
  if (typeof Calendar === 'undefined') {
    sonuc.uyari = 'Devir görüşmesi kurulamadı: Google Calendar API hizmeti ekli değil.';
    return sonuc;
  }
  const gun = oncekiIsGunu_(tarih_(t.baslangic));
  const saat = ayar.devirSaati.split(':').map(Number);
  const bas = new Date(gun.getTime());
  bas.setHours(saat[0] || 16, saat[1] || 0, 0, 0);
  if (bas <= new Date()) {
    sonuc.uyari = 'İzin çok yakın olduğu için devir görüşmesi kurulmadı.';
    return sonuc;
  }
  const bit = new Date(bas.getTime() + ayar.devirSure * 60000);
  const tz = Session.getScriptTimeZone();

  try {
    const etkinlik = Calendar.Events.insert({
      summary: 'Devir görüşmesi: ' + adiBul_(t.email),
      description: adiBul_(t.email) + ', ' + t.baslangic + ' ile ' + t.bitis + ' arasında izinde. ' +
        'Açık işlerin devri için kısa görüşme. Ekip İzin Takvimi tarafından oluşturuldu.',
      start: { dateTime: Utilities.formatDate(bas, tz, "yyyy-MM-dd'T'HH:mm:ss"), timeZone: tz },
      end: { dateTime: Utilities.formatDate(bit, tz, "yyyy-MM-dd'T'HH:mm:ss"), timeZone: tz },
      attendees: [{ email: t.email }, { email: yoneticiEmail }],
      conferenceData: {
        createRequest: { requestId: Utilities.getUuid(), conferenceSolutionKey: { type: 'hangoutsMeet' } }
      }
    }, 'primary', { conferenceDataVersion: 1, sendUpdates: 'all' });

    sonuc.id = etkinlik.id;
    sonuc.link = etkinlik.hangoutLink || '';
    sonuc.zaman = Utilities.formatDate(bas, tz, 'yyyy-MM-dd HH:mm');
  } catch (e) {
    Logger.log('Devir görüşmesi kurulamadı: ' + e);
    sonuc.uyari = 'Devir görüşmesi kurulamadı, izin yine de onaylandı.';
  }
  return sonuc;
}

/* ---------- Yardımcılar (sonu _ ile biten fonksiyonlar tarayıcıdan çağrılamaz) ---------- */

function aktifKullanici_() {
  const e = (Session.getActiveUser().getEmail() || '').toLowerCase();
  if (!e) throw new Error('Kimliğin okunamadı. Uygulamayı şirket Google hesabınla açtığından emin ol.');
  return e;
}

function tablo_() {
  const id = PropertiesService.getScriptProperties().getProperty('SS_ID');
  if (!id) throw new Error('Kurulum yapılmamış. Tablodaki İzin Takvimi menüsünden kurulumu çalıştır.');
  return SpreadsheetApp.openById(id);
}

function talepSayfasi_() {
  return tablo_().getSheetByName(SAYFA_TALEP);
}

function takvim_() {
  const takvim = CalendarApp.getCalendarById(PropertiesService.getScriptProperties().getProperty('TAKVIM_ID'));
  if (!takvim) throw new Error('Ekip takvimi bulunamadı. Kurulumu yeniden çalıştır.');
  return takvim;
}

function ayarlariOku_() {
  const ayar = tablo_().getSheetByName(SAYFA_AYAR);
  const d = ayar.getRange('B2:B5').getDisplayValues().map(r => String(r[0]).trim());
  return {
    yoneticiler: d[0].split(',').map(s => s.trim().toLowerCase()).filter(Boolean),
    limit: Number(d[1]) || 2,
    devirSaati: /^\d{1,2}:\d{2}$/.test(d[2]) ? d[2] : '16:00',
    devirSure: Number(d[3]) || 30
  };
}

function talepleriOku_() {
  const veri = talepSayfasi_().getDataRange().getDisplayValues();
  return veri.slice(1).map((r, i) => ({
    satir: i + 2, id: r[0], olusturma: r[1], email: String(r[2]).toLowerCase(),
    baslangic: r[3], bitis: r[4], tur: r[5], gun: Number(r[6]) || 0, not: r[7],
    durum: r[8], kararVeren: r[9], kararTarihi: r[10], yoneticiNotu: r[11],
    etkinlikId: r[12] || '', devirId: r[13] || '', meetLink: r[14] || ''
  })).filter(t => t.id);
}

function satirEkle_(degerler) {
  const sayfa = talepSayfasi_();
  const satir = sayfa.getLastRow() + 1;
  if (satir > sayfa.getMaxRows()) sayfa.insertRowsAfter(sayfa.getMaxRows(), 100);
  sayfa.getRange(satir, 1, 1, degerler.length).setNumberFormat('@').setValues([degerler]);
}

function bildir_(alici, konu, govde) {
  if (!alici) return;
  try {
    MailApp.sendEmail({ to: alici, subject: konu, body: govde, name: 'Ekip İzin Takvimi' });
  } catch (e) {
    Logger.log('E-posta gönderilemedi: ' + e);
  }
}

function uygulamaUrl_() {
  try { return ScriptApp.getService().getUrl() || ''; } catch (e) { return ''; }
}

function adiBul_(email) {
  return String(email).split('@')[0].split(/[._-]+/).filter(Boolean)
    .map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ');
}

function cakisir_(t, bas, bit) {
  return t.baslangic <= bit && bas <= t.bitis;
}

function tarih_(s) {
  const p = String(s).split('-').map(Number);
  return new Date(p[0], p[1] - 1, p[2]);
}

function gunEkle_(d, n) {
  const x = new Date(d.getTime());
  x.setDate(x.getDate() + n);
  return x;
}

function haftaSonuMu_(d) {
  const g = d.getDay();
  return g === 0 || g === 6;
}

function oncekiIsGunu_(d) {
  let x = gunEkle_(d, -1);
  while (haftaSonuMu_(x)) x = gunEkle_(x, -1);
  return x;
}

function sonrakiIsGunu_(d) {
  let x = gunEkle_(d, 1);
  while (haftaSonuMu_(x)) x = gunEkle_(x, 1);
  return x;
}

function metin_(d) {
  return Utilities.formatDate(d, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}

function bugun_() {
  return metin_(new Date());
}

function simdi_() {
  return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm');
}

function isGunleri_(bas, bit) {
  const gunler = [];
  const son = tarih_(bit);
  for (let d = tarih_(bas); d <= son; d = gunEkle_(d, 1)) {
    if (!haftaSonuMu_(d)) gunler.push(metin_(d));
  }
  return gunler;
}

Adım 5: Arayüzü yapıştır (Index.html)

Sol menüde Dosyalar yanındaki + işaretine tıkla, HTML'i seç ve dosyanın adını tam olarak Index yaz (uzantıyı editör kendisi ekler). İçeriğini silip aşağıdakini yapıştır. Arayüz Google'ın açık lisanslı fontu Google Sans Flex'i ve Google'ın marka renklerini kullanıyor.

<!DOCTYPE html>
<html lang="tr">
<head>
<base target="_top">
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Google+Sans+Flex:wght@400;500;700&display=swap" rel="stylesheet">
<style>
  :root {
    --zemin: #F8F9FA;
    --yuzey: #FFFFFF;
    --murekkep: #202124;
    --ikincil: #5F6368;
    --cizgi: #DADCE0;
    --mavi: #4285F4;
    --mavi-koyu: #185ABC;
    --mavi-acik: #D2E3FC;
    --yesil-koyu: #137333;
    --yesil-acik: #CEEAD6;
    --sari: #FBBC04;
    --sari-koyu: #EA8600;
    --kirmizi: #EA4335;
    --kirmizi-koyu: #B31412;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0;
    background: var(--zemin);
    color: var(--murekkep);
    font: 15px/1.5 "Google Sans Flex", "Google Sans", -apple-system, "Segoe UI", sans-serif;
  }
  .kap { max-width: 960px; margin: 0 auto; padding: 20px 16px 64px; }
  header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
  h1 { font-size: 22px; font-weight: 700; margin: 0; }
  .kim { color: var(--ikincil); font-size: 14px; }
  .rozet-yonetici { background: var(--mavi-acik); color: var(--mavi-koyu); border-radius: 12px; padding: 2px 10px; font-size: 12px; font-weight: 700; margin-left: 6px; }

  nav { display: flex; gap: 4px; border-bottom: 1px solid var(--cizgi); margin-bottom: 20px; overflow-x: auto; }
  nav button {
    border: 0; background: none; padding: 10px 12px; font: inherit; color: var(--ikincil);
    border-bottom: 3px solid transparent; margin-bottom: -1px; cursor: pointer; white-space: nowrap;
  }
  nav button[aria-selected="true"] { color: var(--mavi-koyu); border-bottom-color: var(--mavi); font-weight: 700; }
  .sayac { background: var(--kirmizi-koyu); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 12px; margin-left: 4px; }

  section[hidden] { display: none; }
  .panel { background: var(--yuzey); border: 1px solid var(--cizgi); border-radius: 12px; padding: 16px; }

  .ay-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
  .ay-bar h2 { font-size: 18px; font-weight: 700; margin: 0; }
  .ay-bar button { border: 1px solid var(--cizgi); background: var(--yuzey); border-radius: 20px; width: 40px; height: 40px; font-size: 18px; cursor: pointer; color: var(--murekkep); }

  .izgara { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 1px; background: var(--cizgi); border: 1px solid var(--cizgi); border-radius: 12px; overflow: hidden; }
  .gun-adi { background: var(--zemin); font-size: 12px; color: var(--ikincil); padding: 6px; text-align: center; }
  .hucre { background: var(--yuzey); min-height: 84px; padding: 4px; display: flex; flex-direction: column; gap: 2px; }
  .hucre.bos { background: var(--zemin); }
  .hucre.haftasonu { background: #F1F3F4; }
  .hucre .no { font-size: 12px; color: var(--ikincil); line-height: 1.6; }
  .hucre.bugun .no { color: #fff; background: var(--mavi-koyu); border-radius: 10px; width: 22px; text-align: center; font-weight: 700; }
  .hucre.asim { box-shadow: inset 0 4px 0 var(--kirmizi); }
  .izin { display: block; font-size: 12px; line-height: 1.5; padding: 0 6px; border-radius: 4px; background: var(--yesil-acik); color: var(--yesil-koyu); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .izin.bekliyor { background: transparent; color: var(--murekkep); border: 1px dashed var(--sari-koyu); }
  .fazla { font-size: 11px; color: var(--ikincil); padding-left: 4px; }

  .lejant { display: flex; gap: 16px; flex-wrap: wrap; font-size: 13px; color: var(--ikincil); margin: 10px 0 20px; }
  .lejant span::before { content: ""; display: inline-block; width: 12px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: -1px; }
  .lejant .l-onay::before { background: var(--yesil-acik); border: 1px solid var(--yesil-koyu); }
  .lejant .l-bekle::before { border: 1px dashed var(--sari-koyu); }
  .lejant .l-asim::before { background: var(--kirmizi); height: 4px; vertical-align: 3px; }

  h3 { font-size: 15px; font-weight: 700; margin: 0 0 8px; }
  .liste { list-style: none; margin: 0; padding: 0; }
  .liste li { border-top: 1px solid var(--cizgi); padding: 12px 0; display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; flex-wrap: wrap; }
  .liste li:first-child { border-top: 0; }
  .baslik { font-weight: 700; }
  .alt { color: var(--ikincil); font-size: 13px; }
  .alt a { color: var(--mavi-koyu); font-weight: 500; }
  .durum { font-size: 12px; font-weight: 700; padding: 2px 10px; border-radius: 12px; white-space: nowrap; }
  .d-Bekliyor { background: var(--sari); color: var(--murekkep); }
  .d-Onaylandı { background: var(--yesil-acik); color: var(--yesil-koyu); }
  .d-Reddedildi { background: var(--kirmizi-koyu); color: #fff; }
  .d-İptal { background: #F1F3F4; color: var(--ikincil); }

  form { display: grid; gap: 14px; max-width: 480px; }
  label { display: grid; gap: 4px; font-size: 14px; font-weight: 500; }
  input, select, textarea { font: inherit; padding: 9px 12px; border: 1px solid var(--cizgi); border-radius: 8px; background: var(--yuzey); color: var(--murekkep); }
  textarea { resize: vertical; min-height: 64px; }
  .iki { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
  .ozet { font-size: 14px; color: var(--mavi-koyu); background: var(--mavi-acik); border-radius: 8px; padding: 8px 12px; }
  .btn { font: inherit; font-weight: 700; border-radius: 20px; padding: 9px 20px; cursor: pointer; border: 1px solid transparent; }
  .btn-ana { background: var(--mavi-koyu); color: #fff; }
  .btn-onay { background: var(--yesil-koyu); color: #fff; }
  .btn-red { background: var(--yuzey); color: var(--kirmizi-koyu); border-color: var(--kirmizi-koyu); }
  .btn-sade { background: var(--yuzey); color: var(--ikincil); border-color: var(--cizgi); padding: 5px 12px; font-size: 13px; }
  .btn:disabled { opacity: .55; cursor: wait; }
  .eylem { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; width: 100%; }
  .eylem input { flex: 1 1 200px; }
  .ipucu { width: 100%; font-size: 12px; color: var(--ikincil); }
  .cakisma { color: var(--kirmizi-koyu); font-size: 13px; font-weight: 500; }
  .bos-mesaj { color: var(--ikincil); }

  button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--mavi); outline-offset: 2px; }

  #bildirim { position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); max-width: calc(100% - 32px); background: var(--murekkep); color: #fff; padding: 10px 16px; border-radius: 8px; font-size: 14px; display: none; }
  #bildirim.hata { background: var(--kirmizi-koyu); }
  #yukleniyor { color: var(--ikincil); padding: 40px 0; text-align: center; }

  @media (max-width: 600px) {
    .hucre { min-height: 56px; }
    .izin { font-size: 10px; padding: 0 3px; }
    .iki { grid-template-columns: 1fr; }
  }
</style>
</head>
<body>
<div class="kap">
  <header>
    <h1>Ekip İzin Takvimi</h1>
    <div class="kim" id="kim"></div>
  </header>

  <div id="yukleniyor">Yükleniyor</div>

  <div id="uygulama" hidden>
    <nav role="tablist">
      <button role="tab" data-sekme="takvim" aria-selected="true">Takvim</button>
      <button role="tab" data-sekme="yeni" aria-selected="false">Yeni talep</button>
      <button role="tab" data-sekme="taleplerim" aria-selected="false">Taleplerim</button>
      <button role="tab" data-sekme="onay" aria-selected="false" id="onaySekme" hidden>Onay bekleyenler</button>
    </nav>

    <section id="takvim">
      <div class="ay-bar">
        <button type="button" id="onceki" aria-label="Önceki ay">‹</button>
        <h2 id="ayBaslik"></h2>
        <button type="button" id="sonraki" aria-label="Sonraki ay">›</button>
      </div>
      <div class="izgara" id="izgara"></div>
      <div class="lejant">
        <span class="l-onay">Onaylı izin</span>
        <span class="l-bekle">Onay bekliyor</span>
        <span class="l-asim">Günlük izin sınırı aşıldı</span>
      </div>
      <div class="panel">
        <h3>Bu ay izinli olanlar</h3>
        <ul class="liste" id="ayListe"></ul>
      </div>
    </section>

    <section id="yeni" hidden>
      <div class="panel">
        <form id="talepForm">
          <div class="iki">
            <label>Başlangıç <input type="date" name="baslangic" required></label>
            <label>Bitiş <input type="date" name="bitis" required></label>
          </div>
          <label>İzin türü <select name="tur" id="turSecim" required></select></label>
          <label>Not (isteğe bağlı) <textarea name="not" maxlength="300" placeholder="Yöneticinin bilmesi gereken bir şey varsa yaz"></textarea></label>
          <div class="ozet" id="gunOzet">Tarih seçince iş günü sayısı burada görünür.</div>
          <div><button class="btn btn-ana" type="submit" id="gonderBtn">Talebi gönder</button></div>
        </form>
      </div>
    </section>

    <section id="taleplerim" hidden>
      <div class="panel"><ul class="liste" id="taleplerimListe"></ul></div>
    </section>

    <section id="onay" hidden>
      <div class="panel"><ul class="liste" id="onayListe"></ul></div>
    </section>
  </div>
</div>

<div id="bildirim" role="status" aria-live="polite"></div>

<script>
  let V = null;
  let ay = new Date(); ay.setDate(1);
  const $ = s => document.querySelector(s);
  const pad = n => String(n).padStart(2, '0');
  const tarih = s => { const p = s.split('-').map(Number); return new Date(p[0], p[1] - 1, p[2]); };
  const goster = s => tarih(s).toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' });
  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const BOS_OZET = 'Tarih seçince iş günü sayısı burada görünür.';

  function cagir(fn, ...args) {
    return new Promise((ok, hata) => {
      google.script.run.withSuccessHandler(ok).withFailureHandler(hata)[fn](...args);
    });
  }

  let bildirimZaman;
  function bildir(metin, hata) {
    const b = $('#bildirim');
    b.textContent = metin;
    b.className = hata ? 'hata' : '';
    b.style.display = 'block';
    clearTimeout(bildirimZaman);
    bildirimZaman = setTimeout(() => { b.style.display = 'none'; }, hata ? 7000 : 6000);
  }

  async function yukle() {
    try {
      V = await cagir('baslangicVerisi');
      $('#yukleniyor').hidden = true;
      $('#uygulama').hidden = false;
      ciz();
    } catch (e) {
      $('#yukleniyor').textContent = e.message;
    }
  }

  async function yenile() {
    V = await cagir('baslangicVerisi');
    ciz();
  }

  function ciz() {
    $('#kim').innerHTML = esc(V.ad) + (V.yonetici ? '<span class="rozet-yonetici">Yönetici</span>' : '');
    $('#turSecim').innerHTML = V.izinTurleri.map(t => '<option>' + esc(t) + '</option>').join('');
    const sekme = $('#onaySekme');
    sekme.hidden = !V.yonetici;
    sekme.innerHTML = 'Onay bekleyenler' + (V.bekleyenler.length ? '<span class="sayac">' + V.bekleyenler.length + '</span>' : '');
    takvimCiz();
    taleplerimCiz();
    if (V.yonetici) onayCiz();
  }

  function sekmeAc(ad) {
    document.querySelectorAll('nav button').forEach(b => b.setAttribute('aria-selected', b.dataset.sekme === ad));
    document.querySelectorAll('section').forEach(s => { s.hidden = s.id !== ad; });
  }
  document.querySelectorAll('nav button').forEach(b => b.addEventListener('click', () => sekmeAc(b.dataset.sekme)));

  /* Takvim */
  function takvimCiz() {
    const y = ay.getFullYear(), m = ay.getMonth();
    $('#ayBaslik').textContent = ay.toLocaleDateString('tr-TR', { month: 'long', year: 'numeric' });
    const gunSayisi = new Date(y, m + 1, 0).getDate();
    const bosluk = (new Date(y, m, 1).getDay() + 6) % 7;
    let h = ['Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt', 'Paz'].map(g => '<div class="gun-adi">' + g + '</div>').join('');
    for (let i = 0; i < bosluk; i++) h += '<div class="hucre bos"></div>';
    for (let d = 1; d <= gunSayisi; d++) {
      const s = y + '-' + pad(m + 1) + '-' + pad(d);
      const hg = new Date(y, m, d).getDay();
      const haftasonu = hg === 0 || hg === 6;
      const kim = haftasonu ? [] : V.takvim.filter(t => t.baslangic <= s && s <= t.bitis);
      const asim = kim.length > V.limit;
      let cipler = kim.slice(0, 3).map(t =>
        '<span class="izin' + (t.durum === 'Bekliyor' ? ' bekliyor' : '') + '" title="' + esc(t.ad + ' (' + t.durum + ')') + '">' + esc(t.ad.split(' ')[0]) + '</span>'
      ).join('');
      if (kim.length > 3) cipler += '<span class="fazla">+' + (kim.length - 3) + '</span>';
      h += '<div class="hucre' + (haftasonu ? ' haftasonu' : '') + (s === V.bugun ? ' bugun' : '') + (asim ? ' asim' : '') + '"' +
        (asim ? ' title="Bu gün izinli kişi sayısı sınırı aşıyor"' : '') + '><span class="no">' + d + '</span>' + cipler + '</div>';
    }
    $('#izgara').innerHTML = h;

    const ayBas = y + '-' + pad(m + 1) + '-01';
    const aySon = y + '-' + pad(m + 1) + '-' + pad(gunSayisi);
    const liste = V.takvim.filter(t => t.baslangic <= aySon && ayBas <= t.bitis)
      .sort((a, b) => a.baslangic.localeCompare(b.baslangic));
    $('#ayListe').innerHTML = liste.length ? liste.map(t =>
      '<li><div><div class="baslik">' + esc(t.ad) + '</div><div class="alt">' + goster(t.baslangic) + ' ile ' + goster(t.bitis) + ' arası</div></div>' +
      '<span class="durum d-' + esc(t.durum) + '">' + esc(t.durum) + '</span></li>'
    ).join('') : '<li class="bos-mesaj">Bu ay kayıtlı izin yok.</li>';
  }
  $('#onceki').addEventListener('click', () => { ay.setMonth(ay.getMonth() - 1); takvimCiz(); });
  $('#sonraki').addEventListener('click', () => { ay.setMonth(ay.getMonth() + 1); takvimCiz(); });

  /* Yeni talep */
  function isGunu(bas, bit) {
    let n = 0;
    for (let d = tarih(bas); d <= tarih(bit); d.setDate(d.getDate() + 1)) {
      const g = d.getDay();
      if (g !== 0 && g !== 6) n++;
    }
    return n;
  }
  const form = $('#talepForm');
  form.addEventListener('input', () => {
    if (form.baslangic.value && !form.bitis.value) form.bitis.value = form.baslangic.value;
    form.bitis.min = form.baslangic.value;
    const b = form.baslangic.value, s = form.bitis.value;
    $('#gunOzet').textContent = b && s
      ? (s < b ? 'Bitiş tarihi başlangıçtan önce olamaz.' : isGunu(b, s) + ' iş günü (hafta sonları sayılmaz, resmi tatiller sayılır).')
      : BOS_OZET;
  });
  form.addEventListener('submit', async e => {
    e.preventDefault();
    const btn = $('#gonderBtn');
    btn.disabled = true; btn.textContent = 'Gönderiliyor';
    try {
      const sonuc = await cagir('talepGonder', {
        baslangic: form.baslangic.value, bitis: form.bitis.value, tur: form.tur.value, not: form.not.value
      });
      bildir(sonuc.mesaj + (sonuc.uyari ? ' ' + sonuc.uyari : ''));
      form.reset();
      $('#gunOzet').textContent = BOS_OZET;
      await yenile();
      sekmeAc('taleplerim');
    } catch (err) {
      bildir(err.message, true);
    } finally {
      btn.disabled = false; btn.textContent = 'Talebi gönder';
    }
  });

  /* Taleplerim */
  function taleplerimCiz() {
    const iptalEdilebilir = t => t.durum === 'Bekliyor' || (t.durum === 'Onaylandı' && t.baslangic > V.bugun);
    $('#taleplerimListe').innerHTML = V.taleplerim.length ? V.taleplerim.map(t =>
      '<li><div><div class="baslik">' + goster(t.baslangic) + ' ile ' + goster(t.bitis) + ' arası</div>' +
      '<div class="alt">' + esc(t.tur) + ', ' + t.gun + ' iş günü' + (t.not ? '. Not: ' + esc(t.not) : '') + '</div>' +
      (t.yoneticiNotu ? '<div class="alt">Yönetici notu: ' + esc(t.yoneticiNotu) + '</div>' : '') +
      (t.meetLink && t.durum === 'Onaylandı' && t.baslangic > V.bugun
        ? '<div class="alt"><a href="' + esc(t.meetLink) + '" target="_blank" rel="noopener">Devir görüşmesine katıl (Google Meet)</a></div>' : '') +
      '</div>' +
      '<div class="eylem" style="width:auto"><span class="durum d-' + esc(t.durum.split(' ')[0]) + '">' + esc(t.durum) + '</span>' +
      (iptalEdilebilir(t) ? '<button class="btn btn-sade" data-iptal="' + esc(t.id) + '">İptal et</button>' : '') + '</div></li>'
    ).join('') : '<li class="bos-mesaj">Henüz talebin yok. Yeni talep sekmesinden oluşturabilirsin.</li>';
  }
  $('#taleplerimListe').addEventListener('click', async e => {
    const id = e.target.dataset.iptal;
    if (!id || !confirm('Bu talebi iptal etmek istiyor musun?')) return;
    e.target.disabled = true;
    try {
      const sonuc = await cagir('iptal', id);
      bildir(sonuc.mesaj);
      await yenile();
    } catch (err) {
      bildir(err.message, true);
      e.target.disabled = false;
    }
  });

  /* Onay bekleyenler */
  function onayCiz() {
    $('#onayListe').innerHTML = V.bekleyenler.length ? V.bekleyenler.map(t => {
      const digerleri = V.takvim.filter(x => x.durum === 'Onaylandı' && x.ad !== t.ad && x.baslangic <= t.bitis && t.baslangic <= x.bitis);
      return '<li><div><div class="baslik">' + esc(t.ad) + '</div>' +
        '<div class="alt">' + goster(t.baslangic) + ' ile ' + goster(t.bitis) + ' arası, ' + esc(t.tur) + ', ' + t.gun + ' iş günü</div>' +
        (t.not ? '<div class="alt">Not: ' + esc(t.not) + '</div>' : '') +
        (digerleri.length ? '<div class="cakisma">Bu tarihlerde izinli: ' + digerleri.map(x => esc(x.ad)).join(', ') + '</div>' : '') + '</div>' +
        '<div class="eylem"><input type="text" maxlength="300" placeholder="Not (isteğe bağlı)" aria-label="Yönetici notu" data-not="' + esc(t.id) + '">' +
        '<button class="btn btn-onay" data-karar="1" data-id="' + esc(t.id) + '">Onayla</button>' +
        '<button class="btn btn-red" data-karar="0" data-id="' + esc(t.id) + '">Reddet</button>' +
        '<div class="ipucu">Onaylarsan: ekip takvimine eklenir, çalışana davet gider, izinden önceki iş günü Meet linkli devir görüşmesi kurulur.</div></div></li>';
    }).join('') : '<li class="bos-mesaj">Onay bekleyen talep yok.</li>';
  }
  $('#onayListe').addEventListener('click', async e => {
    const id = e.target.dataset.id;
    if (!id) return;
    const onay = e.target.dataset.karar === '1';
    const not = document.querySelector('[data-not="' + id + '"]').value;
    const butonlar = e.target.closest('.eylem').querySelectorAll('button');
    butonlar.forEach(b => { b.disabled = true; });
    try {
      const sonuc = await cagir('karar', id, onay, not);
      bildir(sonuc.mesaj + (sonuc.uyari ? ' ' + sonuc.uyari : ''));
      await yenile();
    } catch (err) {
      bildir(err.message, true);
      butonlar.forEach(b => { b.disabled = false; });
    }
  });

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

Adım 6: Kurulumu çalıştır

Kaydet (Ctrl+S). Editörün üst kısmındaki fonksiyon listesinden kurulum'u seç ve Çalıştır'a bas. Google senden izin isteyecek: E-Tabloyu düzenleme, takvim oluşturma ve etkinlik ekleme, senin adına mail gönderme ve tetikleyici kurma. Bunlar uygulamanın yaptığı işlerin ta kendisi; onayla.

Kurulum tamamlandığında E-Tabloda iki sayfa oluşur. Talepler sayfası veritabanıdır, elle düzenlememen gerekir. Ayarlar sayfasında şu değerleri değiştirebilirsin:

Ayar Ne işe yarar
Yönetici e-posta Onay ekranını görecek ve bildirim alacak kişiler. Birden fazlaysa virgülle ayır.
Aynı gün en fazla izinli kişi Bu sayı aşılınca takvim o günü kırmızıyla işaretler.
Devir görüşmesi saati İzinden önceki iş günü görüşmenin başlayacağı saat, örneğin 16:00.
Devir görüşmesi süresi Dakika cinsinden, varsayılan 30.

Kurulum ayrıca Google Takvim'inde "Ekip İzin Takvimi" adlı yeni bir takvim ve her sabah çalışan hatırlatma tetikleyicisini oluşturur.

Adım 7: Web uygulaması olarak yayınla

Sağ üstten Dağıt > Yeni dağıtım'a tıkla. Tür olarak Web uygulaması'nı seç. "Şu kullanıcı olarak çalıştır" alanında Ben, "Erişimi olanlar" alanında [şirketin alan adı] içindeki herkes seçili olsun. Dağıt'a bas ve çıkan linki kopyala. Ekibine göndereceğin link budur.

"Ben olarak çalıştır" seçimi önemli: uygulama senin yetkinle çalıştığı için ekip arkadaşlarının E-Tabloya erişimi olmasına gerek yok. Kimse tabloyu açıp başkasının kaydını değiştiremez, ama herkes kendi şirket hesabıyla tanınır.

Bir de en sık takılınan nokta: kodu değiştirdikten sonra link otomatik güncellenmez. Değişiklikten sonra Dağıt > Dağıtımları yönet'e gir, kalem simgesine tıkla, sürüm olarak Yeni sürüm'ü seç ve güncelle. Link aynı kalır, içerik yenilenir.

Adım 8: Ekip takvimini paylaş

Google Takvim'i aç, sol menüde "Ekip İzin Takvimi"nin yanındaki üç noktadan Ayarlar ve paylaşım'a gir. "Kuruluşunuzda kullanılabilir yap" seçeneğini aç. Böylece ekip, onaylı izinleri kendi takviminde de görür. Bu adımı kod yapamıyor, elle yapman gerekiyor.

Test et

En az iki hesapla dene: biri çalışan, biri yönetici. Kontrol listesi:

Sık karşılaşılan sorunlar

Belirti Sebep Çözüm
"Kimliğin okunamadı" hatası Uygulama kişisel Gmail hesabıyla ya da başka bir alan adından açıldı Şirket hesabıyla aç
"Devir görüşmesi kurulamadı" uyarısı Google Calendar API hizmeti ekli değil ya da izin çok yakın Manifesti kontrol et; izin ertesi güne aitse görüşme bilinçli olarak atlanır
Kodu değiştirdim, link eski hali gösteriyor Dağıtım güncellenmedi Dağıtımları yönet > Yeni sürüm
Hatırlatma maili gelmiyor Tetikleyici yok ya da gün hafta sonu Editörde Tetikleyiciler sayfasına bak, gerekirse kurulumu tekrar çalıştır
Dağıt menüsünde web uygulaması seçeneği yok Workspace yöneticisi kısıtlamış BT ile konuş

Bu uygulama nerede biter?

Bu örnek bilinçli olarak sade tutuldu. Resmi tatilleri iş günü hesabına katmıyor, izin bakiyesi tutmuyor, tek kademeli onay kullanıyor ve İK sistemiyle konuşmuyor. Bunların her biri eklenebilir, ama her şirkette kuralları farklı. Kendi ekibine uyarlarken en çok zaman bu kuralları netleştirmeye ve doğru kişilere doğru yetkiyi vermeye gidecek; kodun kendisi çoğu zaman işin küçük kısmı.

Takıldığın bir yer olursa

Kurulumda bir adımda takılırsan ya da uygulamayı kendi ekibine uyarlarken soru çıkarsa iletişim sayfasından ya da Instagram'da @bahadir.digital hesabından yazabilirsin. Sık gelen soruları bu yazının sorun giderme tablosuna ekleyeceğim.

Yazıyı ekipte izin takibiyle uğraşan birine göndermek istersen şu kısa mesajı kullanabilirsin:

Ekipte izin takibini Google Sheets, Takvim ve Gmail ile otomatikleştirebiliriz. Ek lisans gerekmiyor, veri bizim Workspace hesabımızda kalıyor. Kurulum adımları ve örnek kod bu yazıda: https://bahadir.digital/claude-uygulamasini-google-apps-script-ile-yayinla/

#citizen-developer #claude #dijital-dönüşüm #otomasyon #rehber