Co se bude dít
V tabulce čekají návrhy nebo případy, které mají být dnes zkontrolované. Místo dalšího modelu použijte pevné pravidlo: jednou denně najít řádky READY a REVIEW s termínem dnes či dříve a poslat jejich ID jednomu internímu kolegovi. Skript nečte schránku, nekontaktuje klienty a neposuzuje obsah návrhu.
Hodí se pro malou frontu s jedním správcem. Vyžaduje Tabulky Google a vložení hotového skriptu do Apps Script; programovat jej nemusíte. Pro tým s existujícími CRM úkoly raději použijte jejich upozornění a nevytvářejte druhou frontu.
Nastavení
- Vytvořte vlastní tabulku. Stáhněte níže prázdnou frontu kteréhokoli úkolu a importujte CSV do záložky Queue. První řádek musí přesně obsahovat id, owner, source_version, packet, status, draft, review_due, last_notice, v tomto pořadí A:H. Jedno ID znamená jeden případ.
- Nastavte sloupce A:H na prostý text. review_due zapisujte jako YYYY-MM-DD, například 2026-10-10; last_notice ponechte prázdné. Nepoužívejte lokální datum 10/11, jehož význam není jednoznačný.
- V Tabulkách otevřete Rozšíření → Apps Script. V novém projektu nahraďte obsah souboru Code.gs celým skriptem níže. Uložte. Do spreadsheetId vložte část adresy tabulky mezi /d/ a /edit, do reviewerEmail pouze svoji nebo schválenou interní adresu. Nastavte skutečné časové pásmo. dryRun zatím ponechte true.
- V horní nabídce funkcí vyberte dailyReview a Run. Prohlédněte požadovaná oprávnění a autorizujte jen vlastní zkontrolovaný projekt. Pokud účet správce spouštění blokuje, neobcházejte jeho pravidla. V Execution log najděte datum, počet a ID vybraných případů; nic se zatím neposílá.
- Po zkouškách níže přepněte dryRun na false a spusťte jednou ručně. Ověřte jeden e-mail na interní adrese a dnešní datum v last_notice u uvedených řádků. Teprve potom otevřete Triggers → Add Trigger: dailyReview, Head, Time-driven, Day timer, zvolené denní časové okno. V nastavení projektu srovnejte časové pásmo se skriptem. Hodinové okno není záruka přesné minuty.
Celý skript pro Apps Script
// Bound Google Sheets script. Configure only a permitted INTERNAL reviewer.
const REVIEW_CONFIG = {
spreadsheetId: 'REPLACE_WITH_SPREADSHEET_ID',
reviewerEmail: 'REPLACE_WITH_YOUR_INTERNAL_EMAIL',
timezone: 'Europe/Prague', // Use America/New_York or your actual business zone.
dryRun: true,
};
function collectDue(rows, today) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) throw new Error('Invalid current date');
const headers = ['id','owner','source_version','packet','status','draft','review_due','last_notice'];
if (JSON.stringify(rows[0]) !== JSON.stringify(headers)) throw new Error('Expected exact A:H headers');
const seen = new Set(); const due = [];
for (let i=1;i<rows.length;i++) {
const row=rows[i]; if (row.every(v=>!String(v).trim())) continue;
const id=String(row[0]).trim(); if (!id || seen.has(id)) throw new Error('Missing or duplicate case ID');
seen.add(id);
const date=String(row[6]).trim(); const status=String(row[4]).trim();
if (!['READY','REVIEW','HOLD','DONE'].includes(status)) throw new Error('Unknown queue status');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(date+'T00:00:00Z').toISOString().slice(0,10)!==date) throw new Error('Invalid review_due');
if (!String(row[1]).trim()) throw new Error('Missing owner');
if (['READY','REVIEW'].includes(status) && date<=today && String(row[7]).trim()!==today) due.push({row:i+1,id,owner:String(row[1]),status});
}
return due;
}
function dailyReview() {
const cfg=REVIEW_CONFIG;
if (cfg.spreadsheetId.startsWith('REPLACE_') || cfg.reviewerEmail.startsWith('REPLACE_')) throw new Error('Set spreadsheet ID and internal reviewer');
const lock=LockService.getScriptLock(); lock.waitLock(10000);
try {
const properties=PropertiesService.getScriptProperties();
// Ambiguous send outcomes require a human check; never blindly retry email.
if (properties.getProperty('pendingReviewSend')) throw new Error('Check prior email outcome and resolve pendingReviewSend first');
const today=Utilities.formatDate(new Date(),cfg.timezone,'yyyy-MM-dd');
const sheet=SpreadsheetApp.openById(cfg.spreadsheetId).getSheetByName('Queue');
if(!sheet)throw new Error('Missing Queue tab');
const rows=sheet.getDataRange().getDisplayValues();
const due=collectDue(rows,today);
const body=due.map(x=>`${x.id} | ${x.owner} | ${x.status}`).join('\n');
if(cfg.dryRun){console.log(JSON.stringify({today,count:due.length,ids:due.map(x=>x.id)}));return;}
if(!due.length)return;
if(MailApp.getRemainingDailyQuota()<1)throw new Error('No email quota');
properties.setProperty('pendingReviewSend',JSON.stringify({today,rows:due.map(x=>x.row),ids:due.map(x=>x.id)}));
MailApp.sendEmail({to:cfg.reviewerEmail,subject:`Queue review ${today}`,body:`Work awaiting review:\n${body}\n\nOpen the original queue and verify each case. This is an internal reminder.`});
// One writer: do not reorder or edit rows while the script is running.
for(const item of due)sheet.getRange(item.row,8).setValue(today);
SpreadsheetApp.flush();
properties.deleteProperty('pendingReviewSend');
} finally {lock.releaseLock();}
}
- Nabídky zůstávají bez odpovědi. — CSV ↓
- Nevíme, kterou nabídku klient schválil. — CSV ↓
- Zakázka je prodaná, ale tým neví, jak začít. — CSV ↓
- Začátek práce stojí na chybějících podkladech. — CSV ↓
- Reporty trvají dlouho a číslům nevěříme. — CSV ↓
- Nevíme, které dodané zakázky jsou zaplacené. — CSV ↓
- Automatizace vynechává práci nebo ji dělá dvakrát. — CSV ↓
Ověření
Smyšlená zkouška: založte A s dnešním termínem a REVIEW, B se zítřejším termínem a READY, C s dnešním termínem a DONE. Všem přidejte vlastníka. Při dryRun má být vybrán pouze A. Po skutečném interním odeslání opakujte spuštění; dnes už nemá poslat stejný řádek znovu.
Zduplikujte ID A nebo vložte neplatné datum. Spuštění musí skončit chybou před odesláním. Odstraňte chybu a zkuste znovu v dryRun. HOLD se nepřipomíná; jeho opravu musí správce řešit samostatně.
Pozastavení a oprava
Pro pozastavení smažte pouze tento časový trigger v Apps Script, nikoli tabulku ani klientská data. Než jej obnovíte, prohlédněte Executions. Limity e-mailu a běhu závisí na účtu; při vyčerpání kvóty se upozornění neodešle. Nepřítomnost e-mailu proto není důkaz, že fronta je prázdná.
Pokud chyba říká pendingReviewSend, výsledek předchozího odeslání je nejistý. Další běhy se zastaví. V Apps Script Project Settings → Script properties otevřete pendingReviewSend a poznamenejte uvedená ID a datum. U interního příjemce ověřte, zda souhrn skutečně dorazil. Pokud ano, u těchto ID doplňte stejné datum do last_notice; pokud ne, ponechte původní datum. Až pak odstraňte pouze vlastnost pendingReviewSend a spusťte jednou s vypnutým triggerem. Při nejistotě ji nemažte a frontu projděte ručně.
Skript chrání souběh vlastních spuštění, nikoli ruční třídění tabulky či cizí workflow. Během běhu řádky nepřesouvejte. Více současných zapisovatelů vyžaduje jiné řešení.
Zdroje
Ověřeno proti dokumentaci 10. října 2026. Výběr řádků a chybové scénáře jsme ověřili lokálními testy; autorizaci a doručení v účtu Google jsme netestovali.