Xây Dựng Web App Thu Thập Thông Tin Bằng Google Sheets & Apps Script

Trong môi trường giáo dục hiện đại, việc tối ưu hóa quy trình thu thập dữ liệu học sinh là một bài toán cần thiết. Thay vì sử dụng các phương pháp thủ công, chúng ta có thể tự xây dựng một Web App đơn giản để người dùng cuối (phụ huynh, học sinh) nhập liệu trực tiếp, với dữ liệu được lưu trữ và quản lý tập trung trên Google Sheets.

Bài viết này sẽ trình bày chi tiết kiến trúc và quy trình triển khai một ứng dụng như vậy, sử dụng nền tảng của Google.

Phần 1: Cấu Trúc Cơ Sở Dữ Liệu với Google Sheets

Trước tiên, chúng ta cần định nghĩa cấu trúc cho nơi lưu trữ dữ liệu. Google Sheets sẽ đóng vai trò như một database đơn giản.

  1. Khởi tạo Spreadsheet: Truy cập sheets.new để tạo một Google Sheet mới. Đặt tên cho file, ví dụ: “Student_Data_2025”.
  2. Định nghĩa Schema (Schema Definition): Đây là bước quan trọng nhất. Cần định nghĩa các trường dữ liệu (cột) ở hàng đầu tiên. API của chúng ta sẽ dựa vào thứ tự này để ghi dữ liệu. Hãy sao chép và dán chính xác các tiêu đề sau vào hàng đầu tiên của trang tính:
    Timestamp, Họ và tên, Lớp, Ngày sinh, Giới tính, Chỗ ở cũ, Chỗ ở mới, Hộ khẩu, Nơi sinh, Quê quán, Đoàn viên, Số CCCD, Ngày cấp CCCD, Nơi cấp CCCD, Dân tộc, Tôn giáo, Diện chính sách, Diện khuyết tật, Diện ưu tiên, Diện ưu đãi, Thuộc hộ, Sở thích, Biết bơi, Cận thị, Chiều cao (cm), Cân nặng (kg)

Cấu trúc dữ liệu đã sẵn sàng. Bước tiếp theo là xây dựng logic xử lý ở phía backend.

Phần 2: Xây Dựng Backend Logic với Google Apps Script

Google Apps Script (GAS) là một nền tảng serverless dựa trên JavaScript, cho phép chúng ta tạo các API đơn giản để tương tác với các dịch vụ của Google.

  1. Khởi tạo dự án Apps Script: Trong file Google Sheet, vào menu Tiện ích mở rộng (Extensions) > Apps Script.

2. Lập trình các Endpoint: Một tab mới sẽ mở ra với file Code.gs. Xóa hết nội dung mặc định và dán toàn bộ đoạn mã dưới đây vào. Mã này tạo ra 2 endpoint chính: doGet để phục vụ giao diện và saveData để xử lý yêu cầu POST.

// Hàm này sẽ hiển thị trang web của bạn khi người dùng truy cập link
function doGet(e) {
  return HtmlService.createHtmlOutputFromFile('index')
      .setTitle("Phiếu Thu Thập Thông Tin Học Sinh")
      .addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}

// Hàm này sẽ được gọi từ trang web để nhận dữ liệu và lưu vào Google Sheet
function saveData(data) {
  try {
    // Lấy trang tính ĐẦU TIÊN trong file, bất kể tên là gì.
    // Điều này an toàn hơn là dùng getSheetByName("Sheet1")
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];

    // Nếu sheet trống, thêm hàng tiêu đề
    if (sheet.getLastRow() === 0) {
      var headers = [
        "Timestamp", "Họ và tên", "Lớp", "Ngày sinh", "Giới tính", "Chỗ ở cũ", "Chỗ ở mới", "Hộ khẩu",
        "Nơi sinh", "Quê quán", "Đoàn viên", "Số CCCD", "Ngày cấp CCCD", "Nơi cấp CCCD",
        "Dân tộc", "Tôn giáo", "Diện chính sách", "Diện khuyết tật", "Diện ưu tiên", "Diện ưu đãi",
        "Thuộc hộ", "Sở thích", "Biết bơi", "Cận thị", "Chiều cao (cm)", "Cân nặng (kg)"
      ];
      sheet.appendRow(headers);
    }

    // Sắp xếp dữ liệu và thêm vào hàng mới
    var rowData = [
      new Date(), data.fullName, data.className, data.dob, data.gender, data.addressOld,
      data.addressNew, data.permanentAddress, data.birthPlace, data.hometown, data.isDoanVien,
      data.cccdNumber, data.cccdIssueDate, data.cccdIssuePlace, data.ethnicity, data.religion,
      data.policyBeneficiary, data.disability, data.priority, data.incentive, data.household,
      data.hobbies.join(', '), data.canSwim, data.hasMyopia, data.height, data.weight
    ];
    sheet.appendRow(rowData);

    // Trả về tín hiệu thành công
    return JSON.stringify({ 'result': 'success' });
  } catch (error) {
    // Ghi lại lỗi để dễ dàng gỡ rối nếu có vấn đề
    Logger.log(error);
    throw new Error('Đã có lỗi xảy ra khi ghi vào Sheet: ' + error.message);
  }
}

3. Nhấn vào biểu tượng Lưu dự án (hình đĩa mềm) để lưu lại.

    Phần 3: Phát Triển Frontend với HTML & JavaScript

    Giao diện người dùng (UI/Frontend) sẽ được xây dựng bằng HTML, CSS (thông qua TailwindCSS CDN) và JavaScript phía client.

    1. Tạo file HTML: Trong trình soạn thảo Apps Script, nhấn vào dấu + bên cạnh Tệp (Files) > Chọn HTML. Đặt tên file là index.

    2. Xóa hết nội dung mẫu và dán mã nguồn giao diện dưới đây vào file index.html:

    <!DOCTYPE html>
    <html lang="vi">
    <head>
        <meta charset="UTF-8">
        <!-- Thẻ meta viewport này sẽ được thêm tự động bởi hàm doGet -->
        <title>Phiếu Thu Thập Thông Tin Học Sinh</title>
        <script src="https://cdn.tailwindcss.com"></script>
        <link rel="preconnect" href="https://fonts.googleapis.com">
        <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
        <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
        <style>
            body { font-family: 'Inter', sans-serif; }
            .form-section { background-color: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); }
            .form-section-header { display: flex; align-items: center; gap: 12px; font-size: 1.125rem; font-weight: 600; color: #0c4a6e; margin-bottom: 20px; }
            .form-label { display: block; font-weight: 500; color: #374151; margin-bottom: 6px; font-size: 0.875rem; }
            .form-input, .form-select { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; transition: border-color 0.2s; }
            .form-input:focus, .form-select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); }
            .form-checkbox-label, .form-radio-label { display: flex; align-items: center; gap: 8px; font-size: 0.875rem; }
            .submit-btn { width: 100%; padding: 12px; background: linear-gradient(to right, #10b981, #059669); color: white; font-weight: 600; border-radius: 8px; border: none; cursor: pointer; transition: all 0.3s; display: flex; justify-content: center; align-items: center; }
            .submit-btn:hover { background: linear-gradient(to right, #059669, #10b981); }
            .submit-btn:disabled { background-color: #9ca3af; cursor: not-allowed; }
            .loader { border: 4px solid #f3f3f3; border-radius: 50%; border-top: 4px solid #3498db; width: 24px; height: 24px; animation: spin 2s linear infinite; }
            @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
            #toast-message { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background-color: #22c55e; color: white; padding: 12px 24px; border-radius: 8px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); opacity: 0; visibility: hidden; transition: opacity 0.5s, visibility 0.5s; z-index: 1000; }
            #toast-message.show { opacity: 1; visibility: visible; }
        </style>
    </head>
    <body class="p-4 sm:p-6 md:p-8 bg-slate-50">
    
        <div class="max-w-4xl mx-auto pb-16">
            <div class="text-center p-6 rounded-xl mb-8" style="background: linear-gradient(135deg, #2dd4bf, #0d9488);">
                <!-- PHẦN CHÈN LOGO -->
                <!-- Thay thế URL trong src="..." bằng link đến logo của bạn -->
                <img src="https://scontent.fsgn2-4.fna.fbcdn.net/v/t39.30808-6/525568601_633257409812092_8475139235726062175_n.jpg?_nc_cat=101&ccb=1-7&_nc_sid=6ee11a&_nc_eui2=AeEh_s0yXneg85v9J_KMcvvE_-jzYli6LnT_6PNiWLoudC0_-LwMeohVsaxonAudm1J6wLpum06lqkPMPGanB1FC&_nc_ohc=dliWmkdDBDkQ7kNvwERle9e&_nc_oc=AdkM27uNoqz95pgjyDBFBo0QBvhnljHIZw9Y6g50lNxI45FQhNmOcO9LGf7eGZQUpv4&_nc_zt=23&_nc_ht=scontent.fsgn2-4.fna&_nc_gid=mSHOC-QbOCH3GdaUWsuIsA&oh=00_AfRmAHIL9Kluc-XAKK4ZBFdmPk_bslNkpdUNLieAkuVK3g&oe=6893DCB6" 
                     alt="Logo Trường" 
                     class="mx-auto mb-4 h-24 w-auto rounded-full border-4 border-white/50"
                     onerror="this.onerror=null;this.src='https://placehold.co/100x100/ffffff/0d9488?text=Lỗi+Ảnh';">
                <!-- KẾT THÚC PHẦN CHÈN LOGO -->
    
                <h1 class="text-2xl sm:text-3xl font-bold text-white">PHIẾU THU THẬP THÔNG TIN HỌC SINH</h1>
                <p class="text-white/90 mt-2">Năm học 2025-2026</p>
            </div>
    
            <form id="student-info-form">
                <div class="form-section">
                    <h2 class="form-section-header">I. THÔNG TIN CÁ NHÂN HỌC SINH</h2>
                    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
                        <div>
                            <label for="fullName" class="form-label">1. Họ và tên đầy đủ <span class="text-red-500">*</span></label>
                            <input type="text" id="fullName" class="form-input" required>
                        </div>
                        <div>
                            <label for="className" class="form-label">Lớp <span class="text-red-500">*</span></label>
                            <input type="text" id="className" class="form-input" placeholder="Ví dụ: 6A1, 10B2..." required>
                        </div>
                        <div class="grid grid-cols-2 gap-4">
                            <div>
                                <label for="dob" class="form-label">Ngày sinh <span class="text-red-500">*</span></label>
                                <input type="date" id="dob" class="form-input" required>
                            </div>
                            <div>
                                <label class="form-label">Giới tính <span class="text-red-500">*</span></label>
                                <div class="flex items-center h-full gap-4">
                                    <label class="form-radio-label"><input type="radio" name="gender" value="Nam" required> Nam</label>
                                    <label class="form-radio-label"><input type="radio" name="gender" value="Nữ"> Nữ</label>
                                </div>
                            </div>
                        </div>
                        <div><label for="addressOld" class="form-label">2. Chỗ ở hiện nay (cũ)</label><input type="text" id="addressOld" class="form-input"></div>
                        <div><label for="addressNew" class="form-label">Chỗ ở hiện nay (mới) <span class="text-red-500">*</span></label><input type="text" id="addressNew" class="form-input" required></div>
                        <div><label for="permanentAddress" class="form-label">3. Hộ khẩu thường trú (mới) <span class="text-red-500">*</span></label><input type="text" id="permanentAddress" class="form-input" required></div>
                        <div><label for="birthPlace" class="form-label">4. Nơi sinh (như trong GKS) <span class="text-red-500">*</span></label><input type="text" id="birthPlace" class="form-input" required></div>
                        <div><label for="hometown" class="form-label">5. Quê quán (mới) <span class="text-red-500">*</span></label><input type="text" id="hometown" class="form-input" required></div>
                        <div>
                            <label class="form-label">6. Đã kết nạp Đoàn chưa? <span class="text-red-500">*</span></label>
                            <div class="flex items-center h-full gap-4">
                                <label class="form-radio-label"><input type="radio" name="doanvien" value="Rồi" required> Rồi</label>
                                <label class="form-radio-label"><input type="radio" name="doanvien" value="Chưa"> Chưa</label>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="form-section">
                    <h2 class="form-section-header">II. CĂN CƯỚC CÔNG DÂN (CCCD)</h2>
                    <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
                        <div><label for="cccdNumber" class="form-label">Số CCCD <span class="text-red-500">*</span></label><input type="text" id="cccdNumber" class="form-input" required></div>
                        <div><label for="cccdIssueDate" class="form-label">Ngày cấp <span class="text-red-500">*</span></label><input type="date" id="cccdIssueDate" class="form-input" required></div>
                        <div><label for="cccdIssuePlace" class="form-label">Nơi cấp <span class="text-red-500">*</span></label><input type="text" id="cccdIssuePlace" class="form-input" required></div>
                    </div>
                </div>
                <div class="form-section">
                     <h2 class="form-section-header">III. THÔNG TIN KHÁC</h2>
                     <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
                        <div><label for="ethnicity" class="form-label">8. Dân tộc <span class="text-red-500">*</span></label><input type="text" id="ethnicity" class="form-input" value="Kinh" required></div>
                        <div><label for="religion" class="form-label">Tôn giáo <span class="text-red-500">*</span></label><input type="text" id="religion" class="form-input" value="Không" required></div>
                        <div><label for="policyBeneficiary" class="form-label">9. Diện chính sách</label><input type="text" id="policyBeneficiary" class="form-input" placeholder="Nếu không có thì bỏ trống"></div>
                        <div><label for="disability" class="form-label">10. Diện khuyết tật</label><input type="text" id="disability" class="form-input" placeholder="Nếu không có thì bỏ trống"></div>
                        <div><label for="priority" class="form-label">11. Diện ưu tiên</label><input type="text" id="priority" class="form-input" placeholder="Nếu không có thì bỏ trống"></div>
                        <div><label for="incentive" class="form-label">12. Diện ưu đãi</label><input type="text" id="incentive" class="form-input" placeholder="Nếu không có thì bỏ trống"></div>
                        <div class="md:col-span-2"><label for="household" class="form-label">13. Thuộc hộ</label><input type="text" id="household" class="form-input" placeholder="Ví dụ: Hộ nghèo, cận nghèo..."></div>
                    </div>
                    <div class="mt-6">
                        <label class="form-label">14. Sở thích/Năng khiếu</label>
                        <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Ca hát"> Ca hát</label>
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Múa"> Múa</label>
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Nhảy"> Nhảy</label>
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Vẽ"> Vẽ</label>
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Bơi lội"> Bơi lội</label>
                            <label class="form-checkbox-label"><input type="checkbox" name="hobbies" value="Bóng chuyền"> Bóng chuyền</label>
                        </div>
                    </div>
                    <div class="grid grid-cols-2 md:grid-cols-4 gap-6 mt-6">
                        <div>
                            <label class="form-label">15. Biết bơi không? <span class="text-red-500">*</span></label>
                            <div class="flex items-center h-full gap-4">
                               <label class="form-radio-label"><input type="radio" name="canSwim" value="Có" required> Có</label>
                               <label class="form-radio-label"><input type="radio" name="canSwim" value="Không"> Không</label>
                            </div>
                        </div>
                        <div>
                            <label class="form-label">16. Cận thị nặng? <span class="text-red-500">*</span></label>
                            <div class="flex items-center h-full gap-4">
                               <label class="form-radio-label"><input type="radio" name="myopia" value="Có" required> Có</label>
                               <label class="form-radio-label"><input type="radio" name="myopia" value="Không"> Không</label>
                            </div>
                        </div>
                        <div><label for="height" class="form-label">17. Chiều cao (cm) <span class="text-red-500">*</span></label><input type="number" id="height" class="form-input" required></div>
                        <div><label for="weight" class="form-label">18. Cân nặng (kg) <span class="text-red-500">*</span></label><input type="number" id="weight" class="form-input" required></div>
                    </div>
                </div>
    
                <button type="submit" id="submit-button" class="submit-btn mt-4">
                    <span id="button-text">LƯU THÔNG TIN</span>
                    <div id="button-loader" class="loader hidden"></div>
                </button>
            </form>
        </div>
    
        <div id="toast-message"></div>
    
        <script>
            const form = document.getElementById('student-info-form');
            const submitButton = document.getElementById('submit-button');
            const buttonText = document.getElementById('button-text');
            const buttonLoader = document.getElementById('button-loader');
            const toast = document.getElementById('toast-message');
    
            form.addEventListener('submit', (e) => {
                e.preventDefault();
                setLoading(true);
    
                const hobbies = [];
                document.querySelectorAll('input[name="hobbies"]:checked').forEach(checkbox => hobbies.push(checkbox.value));
    
                const formData = {
                    fullName: document.getElementById('fullName').value,
                    className: document.getElementById('className').value,
                    dob: document.getElementById('dob').value,
                    gender: document.querySelector('input[name="gender"]:checked')?.value || '',
                    addressOld: document.getElementById('addressOld').value,
                    addressNew: document.getElementById('addressNew').value,
                    permanentAddress: document.getElementById('permanentAddress').value,
                    birthPlace: document.getElementById('birthPlace').value,
                    hometown: document.getElementById('hometown').value,
                    isDoanVien: document.querySelector('input[name="doanvien"]:checked')?.value || '',
                    cccdNumber: document.getElementById('cccdNumber').value,
                    cccdIssueDate: document.getElementById('cccdIssueDate').value,
                    cccdIssuePlace: document.getElementById('cccdIssuePlace').value,
                    ethnicity: document.getElementById('ethnicity').value,
                    religion: document.getElementById('religion').value,
                    policyBeneficiary: document.getElementById('policyBeneficiary').value,
                    disability: document.getElementById('disability').value,
                    priority: document.getElementById('priority').value,
                    incentive: document.getElementById('incentive').value,
                    household: document.getElementById('household').value,
                    hobbies: hobbies,
                    canSwim: document.querySelector('input[name="canSwim"]:checked')?.value || '',
                    hasMyopia: document.querySelector('input[name="myopia"]:checked')?.value || '',
                    height: document.getElementById('height').value,
                    weight: document.getElementById('weight').value,
                };
    
                google.script.run
                    .withSuccessHandler(() => {
                        showToast('Gửi thông tin thành công!');
                        form.reset();
                        setLoading(false);
                    })
                    .withFailureHandler((error) => {
                        console.error('Lỗi từ Apps Script:', error);
                        showToast('Đã xảy ra lỗi khi gửi thông tin.', true);
                        setLoading(false);
                    })
                    .saveData(formData);
            });
    
            function setLoading(isLoading) {
                submitButton.disabled = isLoading;
                if (isLoading) {
                    buttonText.classList.add('hidden');
                    buttonLoader.classList.remove('hidden');
                } else {
                    buttonText.classList.remove('hidden');
                    buttonLoader.classList.add('hidden');
                }
            }
    
            function showToast(message, isError = false) {
                toast.textContent = message;
                toast.style.backgroundColor = isError ? '#ef4444' : '#22c55e';
                toast.classList.add('show');
                setTimeout(() => {
                    toast.classList.remove('show');
                }, 3000);
            }
        </script>
    </body>
    </html>
    

    3. Nhấn Lưu dự án.

      Phần 4: Triển Khai Ứng Dụng Web App (Deployment)

      Đây là bước đưa ứng dụng lên môi trường production.

      1. Trong trình soạn thảo Apps Script, nhấn Triển khai (Deploy) > Tạo một mục triển khai mới (New deployment).
      2. Nhấp vào biểu tượng bánh răng ⚙️ và chọn Ứng dụng web (Web app).
      3. Cấu hình triển khai:
      • Mô tả: (Tùy chọn) Ví dụ: “Production v1.0”.
      • Thực thi với quyền: Tôi (Me).
      • Ai có quyền truy cập: Bất kỳ ai (Anyone).
      1. Nhấn Triển khai (Deploy).
      2. Cấp quyền (authorize) cho ứng dụng nếu được yêu cầu.
      3. Sau khi thành công, sao chép URL của ứng dụng web được cung cấp. Đây chính là endpoint công khai của ứng dụng.

      Phần 5: Tối Ưu Hóa và Mở Rộng

      • Custom Domain: Mặc dù không thể thay đổi trực tiếp URL của Apps Script, bạn có thể sử dụng các dịch vụ rút gọn link như TinyURL.com hoặc Bit.ly để tạo một alias thân thiện hơn.
      • Validation: Có thể thêm JavaScript vào phía client để kiểm tra tính hợp lệ của dữ liệu (ví dụ: định dạng email, số điện thoại) trước khi gửi đi.
      • UI/UX: Tùy chỉnh thêm CSS để giao diện phù hợp hơn với bộ nhận diện thương hiệu của nhà trường.

      Tổng Kết

      Với các công cụ miễn phí từ Google, chúng ta đã xây dựng thành công một Web App hoàn chỉnh để thu thập dữ liệu một cách hiệu quả. Kiến trúc này tuy đơn giản nhưng đáp ứng tốt nhu cầu cơ bản, dễ dàng triển khai và bảo trì mà không yêu cầu chi phí về hosting hay server.

      Related Posts

      Để lại một bình luận

      Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *