Spaces:
Running
Running
Update app.js
Browse files
app.js
CHANGED
|
@@ -1,180 +1,230 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
| 2 |
|
| 3 |
let state = {
|
| 4 |
-
user: null,
|
| 5 |
-
|
| 6 |
data: [],
|
|
|
|
| 7 |
currentRowIndex: null
|
| 8 |
};
|
| 9 |
|
| 10 |
-
//
|
| 11 |
-
// Using raw text POST prevents complex browser CORS Preflight errors with Apps Script
|
| 12 |
async function apiCall(action, payload = {}) {
|
|
|
|
| 13 |
const response = await fetch(GAS_WEB_APP_URL, {
|
| 14 |
method: 'POST',
|
| 15 |
-
body: JSON.stringify(
|
| 16 |
});
|
| 17 |
const res = await response.json();
|
| 18 |
if (res.status === 'error') throw new Error(res.message);
|
| 19 |
return res.data;
|
| 20 |
}
|
| 21 |
|
| 22 |
-
//
|
| 23 |
async function login() {
|
| 24 |
-
const
|
| 25 |
-
const
|
| 26 |
try {
|
| 27 |
-
const data = await apiCall('login', { username:
|
| 28 |
-
state.user =
|
|
|
|
|
|
|
|
|
|
| 29 |
document.getElementById('login-view').classList.add('d-none');
|
| 30 |
document.getElementById('app-view').classList.remove('d-none');
|
| 31 |
-
document.getElementById('user-role-badge').innerText = `
|
| 32 |
-
|
| 33 |
-
if (data.role === 'Admin') document.getElementById('btn-admin').classList.remove('d-none');
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
} catch (e) {
|
| 37 |
document.getElementById('login-error').classList.remove('d-none');
|
| 38 |
}
|
| 39 |
}
|
| 40 |
|
| 41 |
function logout() {
|
| 42 |
-
state.user = null;
|
| 43 |
document.getElementById('app-view').classList.add('d-none');
|
| 44 |
document.getElementById('login-view').classList.remove('d-none');
|
| 45 |
-
document.getElementById('username').value = '';
|
| 46 |
-
document.getElementById('password').value = '';
|
| 47 |
}
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
const search = document.getElementById('searchInput').value.toLowerCase();
|
| 62 |
-
const filtered = state.data.filter(
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
| 67 |
|
|
|
|
|
|
|
| 68 |
filtered.forEach(row => {
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
});
|
| 73 |
-
|
| 74 |
-
document.getElementById('dataTable').innerHTML = html;
|
| 75 |
}
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
state.
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
-
let html = '';
|
| 83 |
-
state.config.schema.forEach(field => {
|
| 84 |
-
let val = rowData[field.header] || '';
|
| 85 |
-
if (field.type === 'text') {
|
| 86 |
-
html += `<div class="mb-3"><label>${field.header}</label><input type="text" class="form-control data-field" data-key="${field.header}" value="${val}"></div>`;
|
| 87 |
-
} else if (field.type === 'dropdown') {
|
| 88 |
-
let opts = field.options.split(',').map(o => `<option value="${o.trim()}" ${val==o.trim()?'selected':''}>${o.trim()}</option>`).join('');
|
| 89 |
-
html += `<div class="mb-3"><label>${field.header}</label><select class="form-select data-field" data-key="${field.header}"><option value="">Select...</option>${opts}</select></div>`;
|
| 90 |
-
} else if (field.type === 'radio') {
|
| 91 |
-
let opts = field.options.split(',').map((o, i) => `
|
| 92 |
-
<div class="form-check">
|
| 93 |
-
<input class="form-check-input data-field" type="radio" name="${field.header}" data-key="${field.header}" value="${o.trim()}" ${val==o.trim()?'checked':''}>
|
| 94 |
-
<label class="form-check-label">${o.trim()}</label>
|
| 95 |
-
</div>
|
| 96 |
-
`).join('');
|
| 97 |
-
html += `<div class="mb-3"><label>${field.header}</label>${opts}</div>`;
|
| 98 |
-
}
|
| 99 |
-
});
|
| 100 |
-
|
| 101 |
-
document.getElementById('dataFormContainer').innerHTML = html;
|
| 102 |
new coreui.Modal(document.getElementById('dataModal')).show();
|
| 103 |
}
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
}
|
| 125 |
-
new coreui.Modal(document.getElementById('adminModal')).show();
|
| 126 |
}
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
const
|
| 132 |
-
|
| 133 |
-
document.getElementById('adminSheetSelectGroup').classList.remove('d-none');
|
| 134 |
}
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
`).join('');
|
| 154 |
-
|
| 155 |
-
document.getElementById('adminColumnsBody').innerHTML = html;
|
| 156 |
-
document.getElementById('adminColumnsTable').classList.remove('d-none');
|
| 157 |
}
|
| 158 |
|
| 159 |
-
async function
|
| 160 |
-
const
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
header: tr.querySelector('.col-header').innerText,
|
| 164 |
-
type: tr.querySelector('.col-type').value,
|
| 165 |
-
options: tr.querySelector('.col-options').value
|
| 166 |
-
});
|
| 167 |
-
});
|
| 168 |
|
| 169 |
const payload = {
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
| 173 |
};
|
| 174 |
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
|
| 180 |
function closeModal(id) {
|
|
|
|
| 1 |
+
// A simple base64 decode to mildly obfuscate the URL from casual source-code reading.
|
| 2 |
+
const ENCODED_URL = "aHR0cHM6Ly9zY3JpcHQuZ29vZ2xlLmNvbS9tYWNyb3Mvcy9BS2Z5Y2J3LW5TRXR5N2ZIMkRoM1BQYmdZNVlUOXdNOUY2eWJMR2p2MEZGMjFlYVF5M3g2MGxmZGl2VGJMMU5MLVRhVXlHTXpJZy9leGVj";
|
| 3 |
+
const GAS_WEB_APP_URL = atob(ENCODED_URL);
|
| 4 |
|
| 5 |
let state = {
|
| 6 |
+
user: null, role: null,
|
| 7 |
+
options: { items: [] },
|
| 8 |
data: [],
|
| 9 |
+
currentMode: 'ADD', // 'ADD' or 'EDIT'
|
| 10 |
currentRowIndex: null
|
| 11 |
};
|
| 12 |
|
| 13 |
+
// API Helper
|
|
|
|
| 14 |
async function apiCall(action, payload = {}) {
|
| 15 |
+
const reqPayload = { action, username: state.user, password: state.password, ...payload };
|
| 16 |
const response = await fetch(GAS_WEB_APP_URL, {
|
| 17 |
method: 'POST',
|
| 18 |
+
body: JSON.stringify(reqPayload)
|
| 19 |
});
|
| 20 |
const res = await response.json();
|
| 21 |
if (res.status === 'error') throw new Error(res.message);
|
| 22 |
return res.data;
|
| 23 |
}
|
| 24 |
|
| 25 |
+
// Authentication
|
| 26 |
async function login() {
|
| 27 |
+
const u = document.getElementById('username').value;
|
| 28 |
+
const p = document.getElementById('password').value;
|
| 29 |
try {
|
| 30 |
+
const data = await apiCall('login', { username: u, password: p });
|
| 31 |
+
state.user = u;
|
| 32 |
+
state.password = p;
|
| 33 |
+
state.role = data.role;
|
| 34 |
+
|
| 35 |
document.getElementById('login-view').classList.add('d-none');
|
| 36 |
document.getElementById('app-view').classList.remove('d-none');
|
| 37 |
+
document.getElementById('user-role-badge').innerText = `Role: ${state.role}`;
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
if (state.role === 'User' || state.role === 'Admin') {
|
| 40 |
+
document.getElementById('btn-add-new').classList.remove('d-none');
|
| 41 |
+
}
|
| 42 |
+
await loadInitialData();
|
| 43 |
} catch (e) {
|
| 44 |
document.getElementById('login-error').classList.remove('d-none');
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
function logout() {
|
| 49 |
+
state.user = null; state.password = null; state.role = null;
|
| 50 |
document.getElementById('app-view').classList.add('d-none');
|
| 51 |
document.getElementById('login-view').classList.remove('d-none');
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
+
// Data Loading
|
| 55 |
+
async function loadInitialData() {
|
| 56 |
+
const result = await apiCall('getData');
|
| 57 |
+
state.options = result.options;
|
| 58 |
+
state.data = result.data;
|
| 59 |
+
|
| 60 |
+
populateDropdowns();
|
| 61 |
+
renderTable();
|
| 62 |
}
|
| 63 |
|
| 64 |
+
function populateDropdowns() {
|
| 65 |
+
const buildOptions = (arr, valKey, labelKey = null) =>
|
| 66 |
+
`<option value="">Select...</option>` + arr.map(i => `<option value="${labelKey ? i[valKey] : i}">${labelKey ? i[labelKey] : i}</option>`).join('');
|
| 67 |
|
| 68 |
+
document.getElementById('f_supplier').innerHTML = buildOptions(state.options.suppliers);
|
| 69 |
+
document.getElementById('f_subInv').innerHTML = buildOptions(state.options.subinv);
|
| 70 |
+
document.getElementById('f_item').innerHTML = `<option value="">Select...</option>` +
|
| 71 |
+
state.options.items.map(i => `<option value="${i.display}">${i.display}</option>`).join('');
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function updateUOM() {
|
| 75 |
+
const selectedItemDisplay = document.getElementById('f_item').value;
|
| 76 |
+
const itemData = state.options.items.find(i => i.display === selectedItemDisplay);
|
| 77 |
+
document.getElementById('f_uom').value = itemData ? itemData.uom : '';
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Table Rendering
|
| 81 |
+
function renderTable() {
|
| 82 |
const search = document.getElementById('searchInput').value.toLowerCase();
|
| 83 |
+
const filtered = state.data.filter(r => Object.values(r).some(v => String(v).toLowerCase().includes(search)));
|
| 84 |
|
| 85 |
+
// Build Headers
|
| 86 |
+
let headHTML = `<tr><th>No. STG</th><th>Supplier</th><th>Item</th><th>Status</th>`;
|
| 87 |
+
if (state.role === 'User' || state.role === 'Admin') headHTML += `<th class="text-center">Action</th>`;
|
| 88 |
+
headHTML += `</tr>`;
|
| 89 |
+
document.getElementById('tableHead').innerHTML = headHTML;
|
| 90 |
|
| 91 |
+
// Build Body
|
| 92 |
+
let bodyHTML = '';
|
| 93 |
filtered.forEach(row => {
|
| 94 |
+
let dateStr = row.tglDatang ? new Date(row.tglDatang).toLocaleDateString() : '';
|
| 95 |
+
bodyHTML += `<tr>
|
| 96 |
+
<td>${row.stgManual || '-'}</td>
|
| 97 |
+
<td>${row.supplier}</td>
|
| 98 |
+
<td>${row.item}</td>
|
| 99 |
+
<td><span class="badge ${row.stat === 'OK' ? 'bg-success' : 'bg-secondary'}">${row.stat}</span></td>`;
|
| 100 |
+
|
| 101 |
+
if (state.role === 'User' || state.role === 'Admin') {
|
| 102 |
+
bodyHTML += `<td class="text-center">
|
| 103 |
+
<button class="btn btn-warning btn-sm text-white py-0 px-2" onclick="openEditModal(${row._rowIndex})" title="Edit">
|
| 104 |
+
<i class="bi bi-pencil-square"></i>
|
| 105 |
+
</button>
|
| 106 |
+
</td>`;
|
| 107 |
+
}
|
| 108 |
+
bodyHTML += `</tr>`;
|
| 109 |
});
|
| 110 |
+
document.getElementById('tableBody').innerHTML = bodyHTML;
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
+
// Modals & Forms
|
| 114 |
+
function openAddModal() {
|
| 115 |
+
state.currentMode = 'ADD';
|
| 116 |
+
document.getElementById('recordForm').reset();
|
| 117 |
+
document.getElementById('modalTitle').innerText = "Kedatangan Baru";
|
| 118 |
+
|
| 119 |
+
document.getElementById('section-edit').classList.add('d-none');
|
| 120 |
+
document.getElementById('divider-edit').classList.add('d-none');
|
| 121 |
+
|
| 122 |
+
lockFields(false); // Enable base fields
|
| 123 |
+
document.getElementById('btnActionSave').innerText = "Save";
|
| 124 |
+
document.getElementById('btnActionSave').disabled = true;
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
new coreui.Modal(document.getElementById('dataModal')).show();
|
| 127 |
}
|
| 128 |
|
| 129 |
+
function openEditModal(rowIndex) {
|
| 130 |
+
state.currentMode = 'EDIT';
|
| 131 |
+
state.currentRowIndex = rowIndex;
|
| 132 |
+
const rowData = state.data.find(r => r._rowIndex === rowIndex);
|
| 133 |
+
|
| 134 |
+
document.getElementById('modalTitle').innerText = "Edit Row";
|
| 135 |
+
document.getElementById('section-edit').classList.remove('d-none');
|
| 136 |
+
document.getElementById('divider-edit').classList.remove('d-none');
|
| 137 |
+
|
| 138 |
+
// Populate Base Fields
|
| 139 |
+
document.getElementById('f_supplier').value = rowData.supplier;
|
| 140 |
+
document.getElementById('f_tglDatang').value = formatDateForInput(rowData.tglDatang);
|
| 141 |
+
document.getElementById('f_noSj').value = rowData.noSj;
|
| 142 |
+
document.getElementById('f_item').value = rowData.item;
|
| 143 |
+
document.getElementById('f_qtyAwal').value = rowData.qtyAwal;
|
| 144 |
+
document.getElementById('f_qtyReject').value = rowData.qtyReject;
|
| 145 |
+
document.getElementById('f_qtyNett').value = rowData.qtyNett;
|
| 146 |
+
document.getElementById('f_uom').value = rowData.uom;
|
| 147 |
+
document.getElementById('f_subInv').value = rowData.subInv;
|
| 148 |
+
document.getElementById('f_stgManual').value = rowData.stgManual;
|
| 149 |
+
document.getElementById('f_ekspedisi').value = formatDateForInput(rowData.ekspedisi);
|
| 150 |
+
|
| 151 |
+
// Populate Edit Fields (Parse prefix PO/SKL/_)
|
| 152 |
+
const cleanPo = rowData.noPo ? String(rowData.noPo).replace('PO/SKL/_', '') : '';
|
| 153 |
+
document.getElementById('f_noPo').value = cleanPo;
|
| 154 |
+
document.getElementById('f_tglPo').value = formatDateForInput(rowData.tglPo);
|
| 155 |
+
document.getElementById('f_noReceive').value = rowData.noReceive;
|
| 156 |
+
document.getElementById('f_notes').value = rowData.notes;
|
| 157 |
+
|
| 158 |
+
lockFields(true); // Disable non-editable base fields
|
| 159 |
+
document.getElementById('btnActionSave').innerText = "Update";
|
| 160 |
+
document.getElementById('btnActionSave').disabled = true;
|
| 161 |
+
|
| 162 |
+
new coreui.Modal(document.getElementById('dataModal')).show();
|
| 163 |
}
|
| 164 |
|
| 165 |
+
function formatDateForInput(dateString) {
|
| 166 |
+
if (!dateString) return '';
|
| 167 |
+
const d = new Date(dateString);
|
| 168 |
+
return d.toISOString().split('T')[0];
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
+
function lockFields(isEdit) {
|
| 172 |
+
// In Edit mode, Supplier, TglDatang, NoSj, Item are locked.
|
| 173 |
+
// qtyAwal, qtyReject, qtyNett, subInv, stgManual, ekspedisi remain editable.
|
| 174 |
+
const toLock = ['f_supplier', 'f_tglDatang', 'f_noSj', 'f_item'];
|
| 175 |
+
toLock.forEach(id => document.getElementById(id).disabled = isEdit);
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
+
function checkFormValidity() {
|
| 179 |
+
let isValid = true;
|
| 180 |
+
if (state.currentMode === 'ADD') {
|
| 181 |
+
document.querySelectorAll('.req-add').forEach(el => {
|
| 182 |
+
if (!el.value.trim()) isValid = false;
|
| 183 |
+
});
|
| 184 |
+
} else {
|
| 185 |
+
// In edit mode, strict edit fields must be filled
|
| 186 |
+
document.querySelectorAll('.req-edit-strict').forEach(el => {
|
| 187 |
+
if (!el.value.trim()) isValid = false;
|
| 188 |
+
});
|
| 189 |
+
// And base editable fields must still be filled
|
| 190 |
+
document.querySelectorAll('.req-edit').forEach(el => {
|
| 191 |
+
if (!el.value.trim()) isValid = false;
|
| 192 |
+
});
|
| 193 |
+
}
|
| 194 |
+
document.getElementById('btnActionSave').disabled = !isValid;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
|
| 197 |
+
async function submitData() {
|
| 198 |
+
const btn = document.getElementById('btnActionSave');
|
| 199 |
+
btn.disabled = true;
|
| 200 |
+
btn.innerHTML = `<span class="spinner-border spinner-border-sm"></span> Processing...`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
const payload = {
|
| 203 |
+
qtyAwal: document.getElementById('f_qtyAwal').value,
|
| 204 |
+
qtyReject: document.getElementById('f_qtyReject').value,
|
| 205 |
+
qtyNett: document.getElementById('f_qtyNett').value,
|
| 206 |
+
subInv: document.getElementById('f_subInv').value,
|
| 207 |
+
stgManual: document.getElementById('f_stgManual').value.toUpperCase(),
|
| 208 |
+
ekspedisi: document.getElementById('f_ekspedisi').value
|
| 209 |
};
|
| 210 |
|
| 211 |
+
if (state.currentMode === 'ADD') {
|
| 212 |
+
payload.supplier = document.getElementById('f_supplier').value;
|
| 213 |
+
payload.tglDatang = document.getElementById('f_tglDatang').value;
|
| 214 |
+
payload.noSj = document.getElementById('f_noSj').value;
|
| 215 |
+
payload.item = document.getElementById('f_item').value;
|
| 216 |
+
payload.uom = document.getElementById('f_uom').value;
|
| 217 |
+
await apiCall('saveRow', { rowData: payload });
|
| 218 |
+
} else {
|
| 219 |
+
payload.noPo = document.getElementById('f_noPo').value;
|
| 220 |
+
payload.tglPo = document.getElementById('f_tglPo').value;
|
| 221 |
+
payload.noReceive = document.getElementById('f_noReceive').value;
|
| 222 |
+
payload.notes = document.getElementById('f_notes').value;
|
| 223 |
+
await apiCall('updateRow', { rowData: payload, rowIndex: state.currentRowIndex });
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
closeModal('dataModal');
|
| 227 |
+
await loadInitialData();
|
| 228 |
}
|
| 229 |
|
| 230 |
function closeModal(id) {
|