From 8ba925d3dc207d9772f2e9c9f67dfffd40999152 Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Tue, 10 Mar 2026 01:35:04 +0200 Subject: [PATCH] Add leads (liidit) tab for tracking potential customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Liidit tab with table, search, add/edit/delete - Lead fields: company, contact, phone, email, address, city, status, notes - Status workflow: Uusi → Kontaktoitu → Kiinnostunut → Odottaa toimitusta - Color-coded status badges - Detail view with notes display - "Muuta asiakkaaksi" converts lead to customer with pre-filled data - Lead CRUD endpoints in api.php with changelog logging - leads.json added to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + api.php | 128 ++++++++++++++++++++++++++++++++++++++++- index.html | 111 +++++++++++++++++++++++++++++++++++ script.js | 166 +++++++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 35 +++++++++++ 5 files changed, 440 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5b680b3..31f0b7e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ data/customers.json data/users.json data/changelog.json data/archive.json +data/leads.json data/reset_tokens.json data/login_attempts.json data/backups/ diff --git a/api.php b/api.php index 2417ebd..11f86fb 100644 --- a/api.php +++ b/api.php @@ -14,6 +14,7 @@ define('DATA_FILE', DATA_DIR . '/customers.json'); define('USERS_FILE', DATA_DIR . '/users.json'); define('CHANGELOG_FILE', DATA_DIR . '/changelog.json'); define('ARCHIVE_FILE', DATA_DIR . '/archive.json'); +define('LEADS_FILE', DATA_DIR . '/leads.json'); define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json'); define('RATE_FILE', DATA_DIR . '/login_attempts.json'); define('SITE_URL', 'https://intra.cuitunet.fi'); @@ -24,7 +25,7 @@ define('MAIL_FROM_NAME', 'CuituNet Intra'); // Varmista data-kansio ja tiedostot if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true); -foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, TOKENS_FILE, RATE_FILE] as $f) { +foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, LEADS_FILE, TOKENS_FILE, RATE_FILE] as $f) { if (!file_exists($f)) file_put_contents($f, '[]'); } @@ -660,6 +661,131 @@ switch ($action) { echo json_encode(['success' => true]); break; + // ---------- LEADS ---------- + case 'leads': + requireAuth(); + $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + echo json_encode($leads); + break; + + case 'lead_create': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $lead = [ + 'id' => generateId(), + 'yritys' => trim($input['yritys'] ?? ''), + 'yhteyshenkilo' => trim($input['yhteyshenkilo'] ?? ''), + 'puhelin' => trim($input['puhelin'] ?? ''), + 'sahkoposti' => trim($input['sahkoposti'] ?? ''), + 'osoite' => trim($input['osoite'] ?? ''), + 'kaupunki' => trim($input['kaupunki'] ?? ''), + 'tila' => trim($input['tila'] ?? 'uusi'), + 'muistiinpanot' => trim($input['muistiinpanot'] ?? ''), + 'luotu' => date('Y-m-d H:i:s'), + 'luoja' => currentUser(), + ]; + if (empty($lead['yritys'])) { + http_response_code(400); + echo json_encode(['error' => 'Yrityksen nimi vaaditaan']); + break; + } + $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $leads[] = $lead; + file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + addLog('lead_create', $lead['id'], $lead['yritys'], 'Lisäsi liidin'); + echo json_encode($lead); + break; + + case 'lead_update': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $found = false; + foreach ($leads as &$l) { + if ($l['id'] === $id) { + $fields = ['yritys','yhteyshenkilo','puhelin','sahkoposti','osoite','kaupunki','tila','muistiinpanot']; + foreach ($fields as $f) { + if (isset($input[$f])) $l[$f] = trim($input[$f]); + } + $l['muokattu'] = date('Y-m-d H:i:s'); + $l['muokkaaja'] = currentUser(); + $found = true; + addLog('lead_update', $l['id'], $l['yritys'], 'Muokkasi liidiä'); + echo json_encode($l); + break; + } + } + unset($l); + if (!$found) { + http_response_code(404); + echo json_encode(['error' => 'Liidiä ei löydy']); + break; + } + file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + break; + + case 'lead_delete': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $deleted = null; + foreach ($leads as $l) { + if ($l['id'] === $id) { $deleted = $l; break; } + } + $leads = array_values(array_filter($leads, fn($l) => $l['id'] !== $id)); + file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + if ($deleted) addLog('lead_delete', $id, $deleted['yritys'] ?? '', 'Poisti liidin'); + echo json_encode(['success' => true]); + break; + + case 'lead_to_customer': + requireAuth(); + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $id = $input['id'] ?? ''; + $leads = json_decode(file_get_contents(LEADS_FILE), true) ?: []; + $lead = null; + foreach ($leads as $l) { + if ($l['id'] === $id) { $lead = $l; break; } + } + if (!$lead) { + http_response_code(404); + echo json_encode(['error' => 'Liidiä ei löydy']); + break; + } + // Luo asiakas liidistä + $customer = [ + 'id' => generateId(), + 'yritys' => $lead['yritys'], + 'yhteyshenkilö' => $lead['yhteyshenkilo'] ?? '', + 'puhelin' => $lead['puhelin'] ?? '', + 'sahkoposti' => $lead['sahkoposti'] ?? '', + 'laskutusosoite' => '', + 'laskutuspostinumero' => '', + 'laskutuskaupunki' => '', + 'laskutussahkoposti' => '', + 'elaskuosoite' => '', + 'elaskuvalittaja' => '', + 'ytunnus' => '', + 'lisatiedot' => $lead['muistiinpanot'] ?? '', + 'liittymat' => [['asennusosoite' => $lead['osoite'] ?? '', 'postinumero' => '', 'kaupunki' => $lead['kaupunki'] ?? '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => '']], + 'luotu' => date('Y-m-d H:i:s'), + ]; + $customers = loadCustomers(); + $customers[] = $customer; + saveCustomers($customers); + // Poista liidi + $leads = array_values(array_filter($leads, fn($l) => $l['id'] !== $id)); + file_put_contents(LEADS_FILE, json_encode($leads, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + addLog('lead_to_customer', $customer['id'], $customer['yritys'], 'Muutti liidin asiakkaaksi'); + echo json_encode($customer); + break; + // ---------- FILES ---------- case 'file_upload': requireAuth(); diff --git a/index.html b/index.html index 05e67f7..f3bca14 100644 --- a/index.html +++ b/index.html @@ -72,6 +72,7 @@
+ @@ -149,6 +150,42 @@
+ +
+
+
+ + +
+
+ + + + + + + + + + + + +
YritysYhteyshenkilöKaupunkiTilaLisättyToiminnot
+ +
+
+ 0 liidiä +
+
+
+
@@ -370,6 +407,80 @@
+ + + + + + diff --git a/script.js b/script.js index deda62f..90b4826 100644 --- a/script.js +++ b/script.js @@ -182,6 +182,7 @@ document.querySelectorAll('.tab').forEach(tab => { const target = tab.dataset.tab; document.getElementById('tab-content-' + target).classList.add('active'); // Lataa sisältö tarvittaessa + if (target === 'leads') loadLeads(); if (target === 'archive') loadArchive(); if (target === 'changelog') loadChangelog(); if (target === 'users') loadUsers(); @@ -604,6 +605,163 @@ customerForm.addEventListener('submit', async (e) => { await loadCustomers(); }); +// ==================== LEADS ==================== + +let leads = []; +let currentLeadId = null; +const leadModal = document.getElementById('lead-modal'); +const leadDetailModal = document.getElementById('lead-detail-modal'); + +const leadStatusLabels = { + uusi: 'Uusi', + kontaktoitu: 'Kontaktoitu', + kiinnostunut: 'Kiinnostunut', + odottaa: 'Odottaa toimitusta', + ei_kiinnosta: 'Ei kiinnosta', +}; + +async function loadLeads() { + try { + leads = await apiCall('leads'); + renderLeads(); + } catch (e) { console.error(e); } +} + +function renderLeads() { + const query = document.getElementById('lead-search-input').value.toLowerCase().trim(); + let filtered = leads; + if (query) { + filtered = leads.filter(l => + (l.yritys || '').toLowerCase().includes(query) || + (l.yhteyshenkilo || '').toLowerCase().includes(query) || + (l.kaupunki || '').toLowerCase().includes(query) + ); + } + const ltbody = document.getElementById('leads-tbody'); + const noLeads = document.getElementById('no-leads'); + if (filtered.length === 0) { + ltbody.innerHTML = ''; + noLeads.style.display = 'block'; + document.getElementById('leads-table').style.display = 'none'; + } else { + noLeads.style.display = 'none'; + document.getElementById('leads-table').style.display = 'table'; + ltbody.innerHTML = filtered.map(l => ` + ${esc(l.yritys)} + ${esc(l.yhteyshenkilo || '')} + ${esc(l.kaupunki || '')} + ${leadStatusLabels[l.tila] || l.tila || 'Uusi'} + ${esc((l.luotu || '').substring(0, 10))} + + + + + `).join(''); + } + document.getElementById('lead-count').textContent = `${leads.length} liidiä`; +} + +document.getElementById('lead-search-input').addEventListener('input', () => renderLeads()); + +document.getElementById('leads-tbody').addEventListener('click', (e) => { + const row = e.target.closest('tr'); + if (row && row.dataset.leadId) showLeadDetail(row.dataset.leadId); +}); + +function showLeadDetail(id) { + const l = leads.find(x => x.id === id); + if (!l) return; + currentLeadId = id; + document.getElementById('lead-detail-title').textContent = l.yritys; + document.getElementById('lead-detail-body').innerHTML = ` +
+
+
Yritys
${detailVal(l.yritys)}
+
Tila
${leadStatusLabels[l.tila] || 'Uusi'}
+
Yhteyshenkilö
${detailVal(l.yhteyshenkilo)}
+
Puhelin
${detailLink(l.puhelin, 'tel')}
+
Sähköposti
${detailLink(l.sahkoposti, 'email')}
+
Osoite
${detailVal([l.osoite, l.kaupunki].filter(Boolean).join(', '))}
+
Lisätty
${detailVal(l.luotu)} (${esc(l.luoja || '')})
+ ${l.muokattu ? `
Muokattu
${esc(l.muokattu)} (${esc(l.muokkaaja || '')})
` : ''} +
+ ${l.muistiinpanot ? `
MUISTIINPANOT
${esc(l.muistiinpanot)}
` : ''} +
`; + leadDetailModal.style.display = 'flex'; +} + +// Lead form +document.getElementById('btn-add-lead').addEventListener('click', () => openLeadForm()); +document.getElementById('lead-modal-close').addEventListener('click', () => leadModal.style.display = 'none'); +document.getElementById('lead-form-cancel').addEventListener('click', () => leadModal.style.display = 'none'); + +function openLeadForm(lead = null) { + document.getElementById('lead-modal-title').textContent = lead ? 'Muokkaa liidiä' : 'Lisää liidi'; + document.getElementById('lead-form-submit').textContent = lead ? 'Päivitä' : 'Tallenna'; + document.getElementById('lead-form-id').value = lead ? lead.id : ''; + document.getElementById('lead-form-yritys').value = lead ? lead.yritys : ''; + document.getElementById('lead-form-yhteyshenkilo').value = lead ? (lead.yhteyshenkilo || '') : ''; + document.getElementById('lead-form-puhelin').value = lead ? (lead.puhelin || '') : ''; + document.getElementById('lead-form-sahkoposti').value = lead ? (lead.sahkoposti || '') : ''; + document.getElementById('lead-form-osoite').value = lead ? (lead.osoite || '') : ''; + document.getElementById('lead-form-kaupunki').value = lead ? (lead.kaupunki || '') : ''; + document.getElementById('lead-form-tila').value = lead ? (lead.tila || 'uusi') : 'uusi'; + document.getElementById('lead-form-muistiinpanot').value = lead ? (lead.muistiinpanot || '') : ''; + leadModal.style.display = 'flex'; + document.getElementById('lead-form-yritys').focus(); +} + +function editLead(id) { + const l = leads.find(x => x.id === id); + if (l) { leadDetailModal.style.display = 'none'; openLeadForm(l); } +} + +async function deleteLead(id, name) { + if (!confirm(`Poistetaanko liidi "${name}"?`)) return; + await apiCall('lead_delete', 'POST', { id }); + leadDetailModal.style.display = 'none'; + await loadLeads(); +} + +async function convertLeadToCustomer(id) { + const l = leads.find(x => x.id === id); + if (!l) return; + if (!confirm(`Muutetaanko "${l.yritys}" asiakkaaksi?\n\nLiidi poistetaan ja asiakas luodaan sen tiedoilla.`)) return; + await apiCall('lead_to_customer', 'POST', { id }); + leadDetailModal.style.display = 'none'; + await loadLeads(); + await loadCustomers(); +} + +document.getElementById('lead-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const id = document.getElementById('lead-form-id').value; + const data = { + yritys: document.getElementById('lead-form-yritys').value, + yhteyshenkilo: document.getElementById('lead-form-yhteyshenkilo').value, + puhelin: document.getElementById('lead-form-puhelin').value, + sahkoposti: document.getElementById('lead-form-sahkoposti').value, + osoite: document.getElementById('lead-form-osoite').value, + kaupunki: document.getElementById('lead-form-kaupunki').value, + tila: document.getElementById('lead-form-tila').value, + muistiinpanot: document.getElementById('lead-form-muistiinpanot').value, + }; + if (id) { data.id = id; await apiCall('lead_update', 'POST', data); } + else { await apiCall('lead_create', 'POST', data); } + leadModal.style.display = 'none'; + await loadLeads(); +}); + +// Lead detail actions +document.getElementById('lead-detail-close').addEventListener('click', () => leadDetailModal.style.display = 'none'); +document.getElementById('lead-detail-cancel').addEventListener('click', () => leadDetailModal.style.display = 'none'); +document.getElementById('lead-detail-edit').addEventListener('click', () => editLead(currentLeadId)); +document.getElementById('lead-detail-delete').addEventListener('click', () => { + const l = leads.find(x => x.id === currentLeadId); + if (l) deleteLead(currentLeadId, l.yritys); +}); +document.getElementById('lead-detail-convert').addEventListener('click', () => convertLeadToCustomer(currentLeadId)); + // ==================== ARCHIVE ==================== async function loadArchive() { @@ -656,6 +814,10 @@ const actionLabels = { user_create: 'Lisäsi käyttäjän', user_update: 'Muokkasi käyttäjää', user_delete: 'Poisti käyttäjän', + lead_create: 'Lisäsi liidin', + lead_update: 'Muokkasi liidiä', + lead_delete: 'Poisti liidin', + lead_to_customer: 'Muutti liidin asiakkaaksi', }; async function loadChangelog() { @@ -760,11 +922,15 @@ document.getElementById('user-form').addEventListener('submit', async (e) => { customerModal.addEventListener('click', (e) => { if (e.target === customerModal) customerModal.style.display = 'none'; }); detailModal.addEventListener('click', (e) => { if (e.target === detailModal) detailModal.style.display = 'none'; }); userModal.addEventListener('click', (e) => { if (e.target === userModal) userModal.style.display = 'none'; }); +leadModal.addEventListener('click', (e) => { if (e.target === leadModal) leadModal.style.display = 'none'; }); +leadDetailModal.addEventListener('click', (e) => { if (e.target === leadDetailModal) leadDetailModal.style.display = 'none'; }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { customerModal.style.display = 'none'; detailModal.style.display = 'none'; userModal.style.display = 'none'; + leadModal.style.display = 'none'; + leadDetailModal.style.display = 'none'; } }); diff --git a/style.css b/style.css index 3f0d8b2..7ec9338 100644 --- a/style.css +++ b/style.css @@ -1053,6 +1053,41 @@ span.empty { background: #c0392b; } +/* Lead status badges */ +.lead-status { + display: inline-block; + padding: 3px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.3px; +} + +.lead-status-uusi { + background: #3498db; + color: #fff; +} + +.lead-status-kontaktoitu { + background: #f39c12; + color: #fff; +} + +.lead-status-kiinnostunut { + background: #2ecc71; + color: #fff; +} + +.lead-status-odottaa { + background: #9b59b6; + color: #fff; +} + +.lead-status-ei_kiinnosta { + background: #e8ebf0; + color: #888; +} + /* Changelog */ .nowrap { white-space: nowrap;