// ✅ SaaS-КОМАНДА: ДОБАВЛЕНИЕ СТРОКИ НОВОГО СОТРУДНИКА В МАСТЕР-КОНТРОЛЬ
  if (data.action === 'create_team_member') {
   var mSheet = SpreadsheetApp.openById(MASTER_CONTROL_ID).getSheetByName('Пользователи');
   var nextRow = mSheet.getLastRow() + 1;

   // Пишем строго по колонкам Мастера: Логин | Пароль | ID таблицы | Компания | Роль | Родитель
   mSheet.appendRow([
    String(data.new_login).trim().toLowerCase(),
    String(data.new_pass).trim(),
    String(data.sheet_id).trim(),
    String(data.comp_name || "Пользователь CRM").trim(),
    String(data.new_role || "staff").trim().toLowerCase(),
    String(data.parent_owner || "admin").trim().toLowerCase()
   ]);
   SpreadsheetApp.flush();
   return ContentService.createTextOutput(JSON.stringify({result:"success"})).setMimeType(ContentService.MimeType.JSON);
  }

  // 🗑️ SaaS-КОМАНДА: БЕЗОПАСНОЕ УДАЛЕНИЕ СОТРУДНИКА ИЗ МАСТЕР-КОНТРОЛЯ
  if (data.action === 'delete_team_member') {
   var mSheet = SpreadsheetApp.openById(MASTER_CONTROL_ID).getSheetByName('Пользователи');
   var mData = mSheet.getDataRange().getValues();
   var targetLog = String(data.target_login).trim().toLowerCase();
   var parentSsId = String(data.sheet_id).trim();

   // Бежим снизу вверх по Мастеру, ища совпадение логина и ID таблицы владельца
   for (var i = mData.length - 1; i >= 1; i--) {
    if (String(mData[i][0]).trim().toLowerCase() === targetLog && String(mData[i][2]).trim() === parentSsId) {
     mSheet.deleteRow(i + 1); // Вычеркиваем строку сотрудника из базы Мастера
     break;
    }
   }
   SpreadsheetApp.flush();
   return ContentService.createTextOutput(JSON.stringify({result:"success"})).setMimeType(ContentService.MimeType.JSON);
  }

  // =========================================================================
 // 🚀 SaaS-ОБРАБОТЧИК АВТОМАТИЧЕСКОГО ПОДКЛЮЧЕНИЯ ДИСКА И КАЛЕНДАРЯ ИЗ CRM
 // =========================================================================
 else if (data.action === 'setup_google_resource') {
  try {
   var userSs = SpreadsheetApp.openById(data.sheet_id);
   var settingsSheet = userSs.getSheetByName('Настройки') || userSs.getSheetByName('настройки');
   if (!settingsSheet) {
    return ContentService.createTextOutput("error: Лист Настройки не найден").setMimeType(ContentService.MimeType.TEXT);
   }

   var targetUserGmail = String(data.gmail || "").trim();
   var responseText = "";

   if (targetUserGmail !== "" && targetUserGmail.indexOf("@gmail.com") !== -1) {
    settingsSheet.getRange('A2').setValue(targetUserGmail);
   } else {
    if (String(data.login).indexOf("@gmail.com") !== -1) {
     settingsSheet.getRange('A2').setValue(data.login);
     targetUserGmail = data.login;
    }
   }
    } catch(eSetup) {
  console.error("Ошибка в setup_google_resource: " + eSetup.toString());
 }

 SpreadsheetApp.flush();
 }

    // [НОВАЯ ЛОГИКА А]: СОХРАНЕНИЕ ID ПАПКИ КЛИЕНТА
 if (data.type === 'drive') {
  try {
   var currentSheetId = data.sheet_id || data.sheetId || sheetId;
   var targetWorkbook = SpreadsheetApp.openById(currentSheetId);
   var settingsSheet = targetWorkbook.getSheetByName('Настройки') || targetWorkbook.getSheetByName('настройки');

   if (settingsSheet) {
    // Очищаем присланный клиентом ID папки от пробелов
    var clientFolderId = String(data.folder_id || data.folderId || "").trim();

    // Записываем ID папки клиента в ячейку F2 листа Настройки
    settingsSheet.getRange("F2").setValue(clientFolderId);
    responseText = "success_drive_linked:" + clientFolderId;
   } else {
    responseText = "error_settings_sheet_not_found";
   }
  } catch(eDrive) {
   responseText = "error_linking_drive: " + eDrive.toString();
  }
 }

  // [НОВАЯ ЛОГИКА Б]: СОХРАНЕНИЕ ID КАЛЕНДАРЯ КЛИЕНТА
 else if (data.type === 'calendar') {
  try {
   var currentSheetId = data.sheet_id || data.sheetId || sheetId;
   var targetWorkbook = SpreadsheetApp.openById(currentSheetId);
   var settingsSheet = targetWorkbook.getSheetByName('Настройки') || targetWorkbook.getSheetByName('настройки');

   if (settingsSheet) {
    var clientCalendarId = String(data.calendar_id || data.calId || "").trim();
    settingsSheet.getRange("E2").setValue(clientCalendarId);
    responseText = "success_calendar_linked:" + clientCalendarId;
   } else {
    responseText = "error_settings_sheet_not_found";
   }
  } catch(eCal) {
   responseText = "error_linking_calendar: " + eCal.toString();
  }
 }

  // [ИСПРАВЛЕНО ДЛЯ ТИЛЬДЫ]: ПОЛУЧЕНИЕ СОХРАНЕННЫХ НАСТРОЕК (БЕЗ ОШИБОК CORS)
 else if (data.type === 'get_settings') {
  try {
   var currentSheetId = data.sheet_id || data.sheetId || sheetId;
   var targetWorkbook = SpreadsheetApp.openById(currentSheetId);
   var settingsSheet = targetWorkbook.getSheetByName('Настройки') || targetWorkbook.getSheetByName('настройки');

   var savedCalId = "";
   var savedFolderId = "";

   if (settingsSheet) {
    savedCalId = String(settingsSheet.getRange("E2").getValue()).trim();
    savedFolderId = String(settingsSheet.getRange("F2").getValue()).trim();
   }

   var result = {
    status: "success",
    calendar_id: (savedCalId === "undefined" || savedCalId === "null") ? "" : savedCalId,
    folder_id: (savedFolderId === "undefined" || savedFolderId === "null") ? "" : savedFolderId
   };

   // ✅ Магия JSONP: если прилетел параметр callback, оборачиваем ответ в функцию
   var callback = data.callback || "callback";
   var jsonpResponse = callback + "(" + JSON.stringify(result) + ");";

   return ContentService.createTextOutput(jsonpResponse)
              .setMimeType(ContentService.MimeType.JAVASCRIPT);

  } catch(eGet) {
   var errCallback = data.callback || "callback";
   return ContentService.createTextOutput(errCallback + "({status:'error'});")
              .setMimeType(ContentService.MimeType.JAVASCRIPT);
  }
 }

 // 📸 СБОРКА И ЭТАЛОННАЯ БЫСТРАЯ ЗАГРУЗКА ФОТО В ОБЛАКО ЯДРА (КАК БЫЛО В НАЧАЛЕ)//
    var finalPhotoUrl = "";
var base64Data = String(data.file_base64 || "").trim();

if (!userSs && sheetId) {
 userSs = SpreadsheetApp.openById(sheetId);
}

try {
 var settingsSheet = userSs.getSheetByName('Настройки') || userSs.getSheetByName('настройки');
 // Считываем ID персональной папки клиента из ячейки F2
 var userFolderId = settingsSheet ? String(settingsSheet.getRange("F2").getValue()).trim() : "";

 var folder = DriveApp.getRootFolder(); // По умолчанию берем корень

 // Если у клиента указан корректный ID папки, пытаемся подключиться к ней
 if (userFolderId !== "" && userFolderId !== "undefined" && userFolderId.length > 5) {
  try {
   folder = DriveApp.getFolderById(userFolderId);
  } catch(eFindFolder) {
   console.warn("Папка по ID не найдена, используем корневую: " + eFindFolder.toString());
  }
 }  
   // 2. Если данные картинки прилетели и папка готова — запускаем сохранение файла
   if (base64Data !== "" && base64Data !== "undefined" && folder) {
    var mimeType = "image/jpeg";

    if (base64Data.indexOf(';base64,') !== -1) {
     var base64Parts = base64Data.split(';base64,');
     mimeType = base64Parts[0].replace("data:", "").trim();
     base64Data = base64Parts[1];
    } else {
     base64Data = base64Data.replace(/^data:image\/(png|jpeg|jpg|webp);base64,/, "");
    }

    // Создаем один-единственный файл фотографии на вашем Диске Ядра Skitvl84
    var decodedBlob = Utilities.newBlob(Utilities.base64Decode(base64Data), mimeType, data.file_name || "photo.jpg");
    var uploadFile = folder.createFile(decodedBlob);

    // Открываем публичный доступ по ссылке для отображения в интерфейсе CRM
    try {
     uploadFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
    } catch(eSharing) {}

    // Подключаем указанный email из А2 в редакторы этого файла (как было изначально)
    try {
     var clientTargetEmail = settingsSheet ? String(settingsSheet.getRange('A2').getValue()).trim() : "";
     if (clientTargetEmail && clientTargetEmail.indexOf('@gmail.com') !== -1 && clientTargetEmail !== "sktvl84@gmail.com") {
      uploadFile.addEditor(clientTargetEmail);
     }
    } catch(eAddEditor) {}

    // Формируем чистую ссылку для записи в таблицу заказа
    finalPhotoUrl = "https://drive.google.com/file/d/" + uploadFile.getId() + "/view";
   }

  } catch(eMainFile) {
   Logger.log("Критический сбой загрузки файла: " + eMainFile.toString());
  }
  // =======================================================================
  var orderId = "";
  var client = String(data.name || data.client_name || '').trim();
  var phone = String(data.phone || '').trim();
  var status = String(data.status || 'Новое обращение').trim();
  var eventN = String(data.event || data.event_name || '').trim();
  var dateE = String(data.date_event || '');
  var dateD = String(data.date_delivery || '');
  var timeD = String(data.time_delivery || '').trim();
  var products = String(data.products || '').trim();
  var details = String(data.details || '').trim();
  var address = String(data.address || '').trim();
  var manager = String(data.login || 'admin').trim();
  var discountPercent = Math.min(100, Math.max(0, parseInt(String(data.discount || "0").replace("%", ""), 10) || 0));
  var deliveryNum = parseFloat(data.f_deliv_sum || data.delivery || data.f_deliv || 0) || 0;
  var compositionSumNum = parseFloat(data.composition_sum) || 0;
  var finalTotalCalculated = compositionSumNum - Math.round(compositionSumNum * (discountPercent / 100)) + deliveryNum;
  var prepay = parseFloat(data.prepay) || 0;
  var restDebt = finalTotalCalculated - prepay;
  var rawChannel = data.channel || "";

  if (rowNum > 0) {
   orderId = dealsSheet.getRange(rowNum, 1).getValue().toString().trim();
var existingPhotos = "";
try {
 if (rowNum > 0) {
  existingPhotos = String(dealsSheet.getRange(rowNum, 20).getValue()).trim();
 }
} catch(e){}

if (finalPhotoUrl) {
 if (existingPhotos && existingPhotos !== "" && existingPhotos !== "undefined") {
  finalPhotoUrl = existingPhotos + ", " + finalPhotoUrl;
 }
} else {
 finalPhotoUrl = (existingPhotos === "undefined") ? "" : existingPhotos;
}


   dealsSheet.getRange(rowNum, 3).setValue(client); dealsSheet.getRange(rowNum, 4).setValue(phone);
   dealsSheet.getRange(rowNum, 5).setValue(status); dealsSheet.getRange(rowNum, 6).setValue(eventN);
   dealsSheet.getRange(rowNum, 7).setValue(dateE); dealsSheet.getRange(rowNum, 8).setValue(dateD);
   dealsSheet.getRange(rowNum, 9).setValue(String(timeD).trim()).setNumberFormat('@'); dealsSheet.getRange(rowNum, 10).setValue(products);
   dealsSheet.getRange(rowNum, 11).setValue(details); dealsSheet.getRange(rowNum, 12).setValue(address);
   dealsSheet.getRange(rowNum, 13).setValue(compositionSumNum); dealsSheet.getRange(rowNum, 14).setValue(discountPercent).setNumberFormat('#');
   dealsSheet.getRange(rowNum, 15).setValue(deliveryNum); dealsSheet.getRange(rowNum, 16).setValue(finalTotalCalculated);
   dealsSheet.getRange(rowNum, 17).setValue(prepay); dealsSheet.getRange(rowNum, 18).setValue(restDebt);
   dealsSheet.getRange(rowNum, 19).setValue(manager); dealsSheet.getRange(rowNum, 20).setValue(finalPhotoUrl);
   dealsSheet.getRange(rowNum, 21).setValue(rawChannel); dealsSheet.getRange(rowNum, 22).setValue(data.badge || "");
   if (data.calId) dealsSheet.getRange(rowNum, 23).setValue(data.calId);
  } else {
   orderId = "B" + Math.floor(100000 + Math.random() * 900000);
   var nextRow = dealsSheet.getLastRow() + 1;
   var rowValues = [orderId, new Date(), client, phone, status, eventN, dateE, dateD, String(timeD).trim(), products, details, address, compositionSumNum, discountPercent, deliveryNum, finalTotalCalculated, prepay, restDebt, manager, finalPhotoUrl, rawChannel, data.badge || "", data.calId || ""];
  // Подстраховка: если ID календаря не прилетел, принудительно берем его из Настроек таблицы
   if (!data.calId || data.calId === "" || data.calId === "undefined") {
   try {
   var settingsSheet = ss.getSheetByName('Настройки') || ss.getSheetByName('настройки');
   data.calId = settingsSheet ? String(settingsSheet.getRange("E2").getValue()).trim() : "";
   } catch(eCalGet) {}
    }

// Пересобираем массив с уже обновленным ID календаря перед записью строки
rowValues = [orderId, new Date(), client, phone, status, eventN, dateE, dateD, String(timeD).trim(), products, details, address, compositionSumNum, discountPercent, deliveryNum, finalTotalCalculated, prepay, restDebt, manager, finalPhotoUrl, rawChannel, data.badge || "", data.calId || ""];

   dealsSheet.appendRow(rowValues);
if (finalPhotoUrl && finalPhotoUrl !== "" && finalPhotoUrl !== "undefined") {
 dealsSheet.getRange(nextRow, 20).setValue(finalPhotoUrl);
}
dealsSheet.getRange(nextRow, 9).setNumberFormat('@');
dealsSheet.getRange(nextRow, 14).setNumberFormat('#');
rowNum = nextRow;


/// ======= 📅 АВТОМАТИЧЕСКАЯ ОТПРАВКА ЗАКАЗА В КАЛЕНДАРЬ КЛИЕНТА =======
if (typeof dateD !== 'undefined' && dateD !== "" && dateD !== "undefined" && dateD !== null) {
 try {
  var settingsSheet = ss.getSheetByName('Настройки') || ss.getSheetByName('настройки');
  var clientCalendarId = settingsSheet ? String(settingsSheet.getRange("E2").getValue()).trim() : "";

  if (clientCalendarId !== "" && clientCalendarId !== "undefined" && clientCalendarId.length > 10) {
   var targetCalendar = CalendarApp.getCalendarById(clientCalendarId);

   if (targetCalendar) {
    var eventTitle = "🎈 Заказ №" + orderId + " — " + client;
    var eventDescription = "📞 Телефон: " + phone +
                "\n⏰ Время доставки: " + String(timeD).trim() +
                "\n📍 Адрес: " + address +
                "\n📋 Состав: " + details +
                "\n📊 Статус: " + status;

    // 🔥 ПРАВИЛЬНЫЙ ПАРСИНГ ДАТЫ ФОРМАТА ДД.ММ.ГГГГ (Превращаем 14.06.2026 в понятный для JS объект)
    var eventDateStart = new Date();
    var dateParts = String(dateD).split('.');
    if (dateParts.length === 3) {
     // В JS месяцы идут от 0 до 11, поэтому вычитаем 1
     eventDateStart = new Date(parseInt(dateParts[2], 10), parseInt(dateParts[1], 10) - 1, parseInt(dateParts[0], 10));
    } else {
     eventDateStart = new Date(dateD);
    }

        // 1. Корректное регулярное выражение для поиска времени ЧЧ:ММ
    var timeMatch = String(timeD).match(/(\d{1,2}):(\d{2})/);

    if (timeMatch) {
     // 2. Достаем элементы массива совпадений по индексам [1] и [2] в 10-тичной системе
     var hours = parseInt(timeMatch[1], 10);
     var minutes = parseInt(timeMatch[2], 10);

     eventDateStart.setHours(hours);
     eventDateStart.setMinutes(minutes);
     eventDateStart.setSeconds(0); // Очищаем секунды для красоты

     // Создаем конечную точку события (+1 час по умолчанию)
     var eventDateEnd = new Date(eventDateStart.getTime() + (1 * 60 * 60 * 1000));
     targetCalendar.createEvent(eventTitle, eventDateStart, eventDateEnd, {description: eventDescription});
    } else {
     // Если точное время не найдено, создаем событие на весь день
     targetCalendar.createAllDayEvent(eventTitle, eventDateStart, {description: eventDescription});
    }
    console.log("✅ Доставка заказа №" + orderId + " успешно добавлена.");
   }
  }
 } catch(eCalSend) {
  console.error("Ошибка календаря: " + eCalSend.toString());
 }
}

  // Проверка увеличения предоплаты для фиксации в кассу
  var oldPrepay = 0; try { if (rowNum > 0) oldPrepay = Number(dealsSheet.getRange(rowNum, 17).getValue()) || 0; } catch(e){}
  if (prepay > oldPrepay) {
   var amountToRegister = prepay - oldPrepay;
   var paySheet = ss.getSheetByName('реестр_платежей') || ss.insertSheet('реестр_платежей');
   if (paySheet.getLastRow() === 0) paySheet.appendRow(['Дата внесения', 'ID Заказа', 'Клиент', 'Сумма платежа']);
   paySheet.appendRow([new Date(), orderId, client, amountToRegister]);
 }
       // =========================================================================
    // 🔥 ПРЯМОЙ ИЗОЛИРОВАННЫЙ ПРОБРОС НА ЛИСТ "БАЗА_КЛИЕНТОВ" (Строка 1089)
    // =========================================================================
    try {
     var targetUserSheetId = data.sheet_id || data.sheetId || (typeof sheetId !== 'undefined' ? sheetId : "");

     if (targetUserSheetId) {
      var activeUserSpreadsheet = SpreadsheetApp.openById(targetUserSheetId);

      // СТРОГО ВАШ РЕАЛЬНЫЙ ЛИСТ ИЗ CRM v50.0
      var interfaceClientsSheet = activeUserSpreadsheet.getSheetByName('база_клиентов');

      if (interfaceClientsSheet) {
       var orderClientName = String(data.name || data.client_name || (typeof client !== 'undefined' ? client : "Без имени")).trim();
       var orderClientPhone = String(data.phone || data.client_phone || (typeof phone !== 'undefined' ? phone : "")).trim();

       if (orderClientPhone && orderClientPhone !== "") {
        // Последний параметр true означает, что запись идет из заказа
        smartCustomerUpsert(interfaceClientsSheet, orderClientName, orderClientPhone, true);
       }
      }
     }
    } catch (errSync) {
     Logger.log("Заминка изолированного автопроброса клиента в doPost: " + errSync.toString());
    }
    // =========================================================================

 } // ◄ ПОСТАВЬТЕ ЭТУ ФИГУРНУЮ СКОБКУ СЮДА!

 return ContentService.createTextOutput(JSON.stringify({result:"success", row: rowNum, photo: finalPhotoUrl})).setMimeType(ContentService.MimeType.JSON);
 } catch(f) { return ContentService.createTextOutput(JSON.stringify({result:"error", error: f.toString()})).setMimeType(ContentService.MimeType.JSON); }


}
// ✅ МЕТОД ЯДРА: СОЗДАНИЕ ПАПКИ С ЖЕСТКОЙ ВЕРИФИКАЦИЕЙ ПРАВ ВЛАДЕЛЬЦА (СЦЕНАРИЙ 2)
function coreSetupUserGoogleDrive(activeSsId, currentUserEmail) {
 if (!activeSsId) return "error: no sheet id";
 try {
  var ss = SpreadsheetApp.openById(activeSsId);
  var settingsSheet = ss.getSheetByName('Настройки') || ss.getSheetByName('настройки');
  if (!settingsSheet) return "error: Лист 'Настройки' не найден в таблице!";
  var ownerEmail = ss.getOwner() ? ss.getOwner().getEmail() : "";
  var currentEmail = String(currentUserEmail || "").toLowerCase().trim();
  var cleanOwnerEmail = String(ownerEmail).toLowerCase().trim();

  if (currentEmail !== cleanOwnerEmail && cleanOwnerEmail !== "") { return "access_denied:" + ownerEmail; }
  var currentFolderId = String(settingsSheet.getRange("F2").getValue()).trim();
  if (currentFolderId && currentFolderId !== "" && currentFolderId !== "undefined" && currentFolderId.length > 5) { return "already_configured"; }

  var folderName = "📸 Фото заказов — " + ss.getName().replace("Шаблон ", "");
  var newFolder = DriveApp.createFolder(folderName);
  newFolder.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  var folderId = newFolder.getId();

  settingsSheet.getRange("F2").setValue(folderId);
  SpreadsheetApp.flush();
  return "success:" + folderName + "|" + newFolder.getUrl();
 } catch(e) { return "error: " + e.toString(); }
}

// =========================================================================
// 🚀 ЕДИНЫЙ ЦЕНТРАЛЬНЫЙ SaaS-ДВИЖОК ТАБЛИЧНОГО КАЛЬКУЛЯТОРА v69.0
// =========================================================================

// 1. Централизованная выгрузка прайс-листа товаров для сайдбара
// ✅ ЭТАЛОННАЯ SaaS-ВЫГРУЗКА ПРАЙС-ЛИСТА С АВТО-ДЕТЕКЦИЕЙ ГРУПП v72.0
function coreSavePrices(sheetId, clientCatalog) {
 if (!sheetId || !clientCatalog) return "error_bad_data";
 try {
  var catalogArr = clientCatalog;
  if (typeof clientCatalog === "string") {
   var decodedString = clientCatalog;
   try { while (decodedString.indexOf('%') !== -1) { decodedString = decodeURIComponent(decodedString); } } catch(e) {}
   catalogArr = JSON.parse(decodedString);
  }
  if (!Array.isArray(catalogArr)) return "error_not_array";

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var goodsSheet = ss.getSheetByName('Товары') || ss.getSheetByName('товары');
  if (!goodsSheet) return "error_no_sheet";

  var lastRow = goodsSheet.getLastRow();
  if (lastRow > 0) { goodsSheet.getRange(1, 1, lastRow, 2).clearContent(); }

  var rowsToPush = [["", ""]]; var yellowRows = [];
  catalogArr.forEach(function(item) {
   if (!item || !item.name) return;
   var colA = String(item.name).trim();
   if (item.isGroup === true || item.isGroup === "true") {
    rowsToPush.push([colA, ""]); yellowRows.push(rowsToPush.length);
   } else {
    // Парсим цену как дробное число (поддержка центов меньше 1)
    var priceNum = parseFloat(String(item.price).replace(/[^0-9.,]/g, '').replace(',', '.')) || 0;
    rowsToPush.push([colA, priceNum]);
   }
  });

  goodsSheet.getRange(1, 1, rowsToPush.length, 2).setValues(rowsToPush);
  if (lastRow > 0) { goodsSheet.getRange(1, 1, lastRow, 2).setBackground("#ffffff").setFontWeight("normal"); }
  yellowRows.forEach(function(rowIndex) { goodsSheet.getRange(rowIndex, 1, 1, 2).setBackground("#ffff00").setFontWeight("bold"); });
  SpreadsheetApp.flush();
  return "success";
 } catch(e) { return "error: " + e.toString(); }
}



// 2. Универсальный сборщик SaaS-пакета инициализации калькулятора
function getSaaSAppInitPack() {
 var output = { sheetId: "", catalog: [] };
 try {
  var activeSs = SpreadsheetApp.getActiveSpreadsheet();
  output.sheetId = activeSs.getId();
  output.catalog = coreGetPrices(output.sheetId);
 } catch(e) {}
 return output;
}

// 🎯 ОЧИЩЕНО: Физическая запись перенесена в локальные файлы таблиц клиентов для обхода изоляции Google
function coreWriteComposition(sheetId, selectedItemsJson, totalSumStatic) {
 return "success";
}

Made on
Tilda