Add ticket types, move Asiakaspalvelu tab first, hide closed tickets

- Asiakaspalvelu tab moved to first position in navigation
- Added ticket type field (Laskutus, Tekniikka, Vika, Muu) with
  type filter dropdown and type column in ticket list
- Type selector in ticket detail view with API endpoint
- Closed tickets hidden by default (selectable via "Kaikki tilat")
- Käsittelyssä rows highlighted with green background
- Type badges with color coding per category

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 10:01:29 +02:00
parent f0a7676451
commit 91930c9420
4 changed files with 126 additions and 8 deletions

34
api.php
View File

@@ -1655,6 +1655,40 @@ switch ($action) {
saveTickets($tickets); saveTickets($tickets);
break; break;
case 'ticket_type':
requireAuth();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? '';
$type = $input['type'] ?? '';
$validTypes = ['laskutus', 'tekniikka', 'vika', 'muu'];
if (!in_array($type, $validTypes)) {
http_response_code(400);
echo json_encode(['error' => 'Virheellinen tyyppi']);
break;
}
$tickets = loadTickets();
$found = false;
foreach ($tickets as &$t) {
if ($t['id'] === $id) {
$oldType = $t['type'] ?? 'muu';
$t['type'] = $type;
$t['updated'] = date('Y-m-d H:i:s');
$found = true;
addLog('ticket_type', $t['id'], $t['subject'], "Tyyppi: {$oldType}{$type}");
echo json_encode($t);
break;
}
}
unset($t);
if (!$found) {
http_response_code(404);
echo json_encode(['error' => 'Tikettiä ei löydy']);
break;
}
saveTickets($tickets);
break;
case 'ticket_assign': case 'ticket_assign':
requireAuth(); requireAuth();
if ($method !== 'POST') break; if ($method !== 'POST') break;

View File

@@ -72,11 +72,11 @@
<!-- Tabs --> <!-- Tabs -->
<div class="tab-bar"> <div class="tab-bar">
<button class="tab" data-tab="support">Asiakaspalvelu</button>
<button class="tab active" data-tab="customers">Asiakkaat</button> <button class="tab active" data-tab="customers">Asiakkaat</button>
<button class="tab" data-tab="leads">Liidit</button> <button class="tab" data-tab="leads">Liidit</button>
<button class="tab" data-tab="archive">Arkisto</button> <button class="tab" data-tab="archive">Arkisto</button>
<button class="tab" data-tab="changelog">Muutosloki</button> <button class="tab" data-tab="changelog">Muutosloki</button>
<button class="tab" data-tab="support">Asiakaspalvelu</button>
<button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button> <button class="tab" data-tab="settings" id="tab-settings" style="display:none">API</button>
</div> </div>
@@ -241,19 +241,27 @@
<div class="main-container"> <div class="main-container">
<!-- Listanäkymä --> <!-- Listanäkymä -->
<div id="ticket-list-view"> <div id="ticket-list-view">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;gap:0.75rem;flex-wrap:wrap;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;gap:0.75rem;flex-wrap:wrap;">
<button class="btn-primary" id="btn-fetch-emails">&#128231; Hae postit</button> <button class="btn-primary" id="btn-fetch-emails">&#128231; Hae postit</button>
<div class="search-bar" style="flex:1;max-width:400px;"> <div class="search-bar" style="flex:1;max-width:400px;">
<span class="search-icon">&#128269;</span> <span class="search-icon">&#128269;</span>
<input type="text" id="ticket-search-input" placeholder="Hae tiketeistä..."> <input type="text" id="ticket-search-input" placeholder="Hae tiketeistä...">
</div> </div>
<select id="ticket-type-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki tyypit</option>
<option value="laskutus">Laskutus</option>
<option value="tekniikka">Tekniikka</option>
<option value="vika">Vika</option>
<option value="muu">Muu</option>
</select>
<select id="ticket-status-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;"> <select id="ticket-status-filter" style="padding:9px 12px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.88rem;">
<option value="">Kaikki tilat</option> <option value="">Avoimet</option>
<option value="uusi">Uusi</option> <option value="uusi">Uusi</option>
<option value="kasittelyssa">Käsittelyssä</option> <option value="kasittelyssa">Käsittelyssä</option>
<option value="odottaa">Odottaa vastausta</option> <option value="odottaa">Odottaa vastausta</option>
<option value="ratkaistu">Ratkaistu</option> <option value="ratkaistu">Ratkaistu</option>
<option value="suljettu">Suljettu</option> <option value="suljettu">Suljettu</option>
<option value="kaikki">Kaikki tilat</option>
</select> </select>
</div> </div>
<div id="ticket-fetch-status" style="display:none;padding:0.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:0.9rem;"></div> <div id="ticket-fetch-status" style="display:none;padding:0.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:0.9rem;"></div>
@@ -262,6 +270,7 @@
<thead> <thead>
<tr> <tr>
<th>Tila</th> <th>Tila</th>
<th>Tyyppi</th>
<th>Aihe</th> <th>Aihe</th>
<th>Lähettäjä</th> <th>Lähettäjä</th>
<th>Viestejä</th> <th>Viestejä</th>

View File

@@ -958,6 +958,13 @@ const ticketStatusLabels = {
suljettu: 'Suljettu', suljettu: 'Suljettu',
}; };
const ticketTypeLabels = {
laskutus: 'Laskutus',
tekniikka: 'Tekniikka',
vika: 'Vika',
muu: 'Muu',
};
async function loadTickets() { async function loadTickets() {
try { try {
tickets = await apiCall('tickets'); tickets = await apiCall('tickets');
@@ -968,7 +975,20 @@ async function loadTickets() {
function renderTickets() { function renderTickets() {
const query = document.getElementById('ticket-search-input').value.toLowerCase().trim(); const query = document.getElementById('ticket-search-input').value.toLowerCase().trim();
const statusFilter = document.getElementById('ticket-status-filter').value; const statusFilter = document.getElementById('ticket-status-filter').value;
const typeFilter = document.getElementById('ticket-type-filter').value;
let filtered = tickets; let filtered = tickets;
// Default: hide closed tickets unless explicitly selected or "kaikki"
if (!statusFilter || statusFilter === '') {
filtered = filtered.filter(t => t.status !== 'suljettu');
} else if (statusFilter !== 'kaikki') {
filtered = filtered.filter(t => t.status === statusFilter);
}
if (typeFilter) {
filtered = filtered.filter(t => (t.type || 'muu') === typeFilter);
}
if (query) { if (query) {
filtered = filtered.filter(t => filtered = filtered.filter(t =>
(t.subject || '').toLowerCase().includes(query) || (t.subject || '').toLowerCase().includes(query) ||
@@ -976,9 +996,6 @@ function renderTickets() {
(t.from_email || '').toLowerCase().includes(query) (t.from_email || '').toLowerCase().includes(query)
); );
} }
if (statusFilter) {
filtered = filtered.filter(t => t.status === statusFilter);
}
const ttbody = document.getElementById('tickets-tbody'); const ttbody = document.getElementById('tickets-tbody');
const noTickets = document.getElementById('no-tickets'); const noTickets = document.getElementById('no-tickets');
@@ -991,8 +1008,11 @@ function renderTickets() {
document.getElementById('tickets-table').style.display = 'table'; document.getElementById('tickets-table').style.display = 'table';
ttbody.innerHTML = filtered.map(t => { ttbody.innerHTML = filtered.map(t => {
const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;'); const lastType = t.last_message_type === 'reply_out' ? '&#8594;' : (t.last_message_type === 'note' ? '&#128221;' : '&#8592;');
return `<tr data-ticket-id="${t.id}"> const typeLabel = ticketTypeLabels[t.type] || 'Muu';
const rowClass = t.status === 'kasittelyssa' ? 'ticket-row-active' : '';
return `<tr data-ticket-id="${t.id}" class="${rowClass}">
<td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td> <td><span class="ticket-status ticket-status-${t.status}">${ticketStatusLabels[t.status] || t.status}</span></td>
<td><span class="ticket-type ticket-type-${t.type || 'muu'}">${typeLabel}</span></td>
<td><strong>${esc(t.subject)}</strong></td> <td><strong>${esc(t.subject)}</strong></td>
<td>${esc(t.from_name || t.from_email)}</td> <td>${esc(t.from_name || t.from_email)}</td>
<td style="text-align:center;">${lastType} ${t.message_count}</td> <td style="text-align:center;">${lastType} ${t.message_count}</td>
@@ -1001,7 +1021,9 @@ function renderTickets() {
</tr>`; </tr>`;
}).join(''); }).join('');
} }
document.getElementById('ticket-count').textContent = `${tickets.length} tikettiä`;
const openCount = tickets.filter(t => t.status !== 'suljettu').length;
document.getElementById('ticket-count').textContent = `${openCount} avointa tikettiä (${tickets.length} yht.)`;
// Status summary // Status summary
const counts = {}; const counts = {};
@@ -1015,6 +1037,7 @@ function renderTickets() {
document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets()); document.getElementById('ticket-search-input').addEventListener('input', () => renderTickets());
document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets()); document.getElementById('ticket-status-filter').addEventListener('change', () => renderTickets());
document.getElementById('ticket-type-filter').addEventListener('change', () => renderTickets());
document.getElementById('tickets-tbody').addEventListener('click', (e) => { document.getElementById('tickets-tbody').addEventListener('click', (e) => {
const row = e.target.closest('tr'); const row = e.target.closest('tr');
@@ -1036,6 +1059,12 @@ async function showTicketDetail(id) {
</div> </div>
</div> </div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;"> <div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<select id="ticket-type-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="muu" ${(ticket.type || 'muu') === 'muu' ? 'selected' : ''}>Muu</option>
<option value="laskutus" ${ticket.type === 'laskutus' ? 'selected' : ''}>Laskutus</option>
<option value="tekniikka" ${ticket.type === 'tekniikka' ? 'selected' : ''}>Tekniikka</option>
<option value="vika" ${ticket.type === 'vika' ? 'selected' : ''}>Vika</option>
</select>
<select id="ticket-status-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;"> <select id="ticket-status-select" style="padding:6px 10px;border:2px solid #e0e0e0;border-radius:8px;font-size:0.85rem;">
<option value="uusi" ${ticket.status === 'uusi' ? 'selected' : ''}>Uusi</option> <option value="uusi" ${ticket.status === 'uusi' ? 'selected' : ''}>Uusi</option>
<option value="kasittelyssa" ${ticket.status === 'kasittelyssa' ? 'selected' : ''}>Käsittelyssä</option> <option value="kasittelyssa" ${ticket.status === 'kasittelyssa' ? 'selected' : ''}>Käsittelyssä</option>
@@ -1063,6 +1092,13 @@ async function showTicketDetail(id) {
}); });
} catch (e) { /* non-admin may not access users */ } } catch (e) { /* non-admin may not access users */ }
// Type change handler
document.getElementById('ticket-type-select').addEventListener('change', async function() {
try {
await apiCall('ticket_type', 'POST', { id: currentTicketId, type: this.value });
} catch (e) { alert(e.message); }
});
// Status change handler // Status change handler
document.getElementById('ticket-status-select').addEventListener('change', async function() { document.getElementById('ticket-status-select').addEventListener('change', async function() {
try { try {

View File

@@ -1124,6 +1124,45 @@ span.empty {
color: #555; color: #555;
} }
/* Ticket type badges */
.ticket-type {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.ticket-type-laskutus {
background: #e8f5e9;
color: #2e7d32;
}
.ticket-type-tekniikka {
background: #e3f2fd;
color: #1565c0;
}
.ticket-type-vika {
background: #fce4ec;
color: #c62828;
}
.ticket-type-muu {
background: #f5f5f5;
color: #757575;
}
/* Active (käsittelyssä) ticket row — green tint */
.ticket-row-active {
background: #e8f8e8 !important;
}
.ticket-row-active:hover {
background: #d5f0d5 !important;
}
/* Ticket thread */ /* Ticket thread */
.ticket-thread { .ticket-thread {
display: flex; display: flex;