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:
34
api.php
34
api.php
@@ -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;
|
||||||
|
|||||||
15
index.html
15
index.html
@@ -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">📧 Hae postit</button>
|
<button class="btn-primary" id="btn-fetch-emails">📧 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">🔍</span>
|
<span class="search-icon">🔍</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>
|
||||||
|
|||||||
46
script.js
46
script.js
@@ -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' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
const lastType = t.last_message_type === 'reply_out' ? '→' : (t.last_message_type === 'note' ? '📝' : '←');
|
||||||
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 {
|
||||||
|
|||||||
39
style.css
39
style.css
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user