Add CuituNet Intra customer management CMS
Password-protected intranet for managing fiber internet customers: - Customer table (company, address, speed, price) - Click row to view full details (contact & billing info) - Add, edit, delete customers - Search and sortable columns - Total billing summary - PHP + vanilla JS + JSON storage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/customers.json
|
||||||
153
api.php
Normal file
153
api.php
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<?php
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
define('ADMIN_PASSWORD', 'cuitunet2024');
|
||||||
|
define('DATA_FILE', __DIR__ . '/data/customers.json');
|
||||||
|
|
||||||
|
// Varmista data-kansio
|
||||||
|
if (!file_exists(__DIR__ . '/data')) {
|
||||||
|
mkdir(__DIR__ . '/data', 0755, true);
|
||||||
|
}
|
||||||
|
if (!file_exists(DATA_FILE)) {
|
||||||
|
file_put_contents(DATA_FILE, '[]');
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
|
// Auth-tarkistus (paitsi login)
|
||||||
|
function requireAuth() {
|
||||||
|
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Kirjaudu sisään']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCustomers(): array {
|
||||||
|
$data = file_get_contents(DATA_FILE);
|
||||||
|
return json_decode($data, true) ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCustomers(array $customers): void {
|
||||||
|
file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return bin2hex(random_bytes(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($action) {
|
||||||
|
case 'login':
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$password = $input['password'] ?? '';
|
||||||
|
if ($password === ADMIN_PASSWORD) {
|
||||||
|
$_SESSION['authenticated'] = true;
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['error' => 'Väärä salasana']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'logout':
|
||||||
|
session_destroy();
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'check_auth':
|
||||||
|
echo json_encode(['authenticated' => isset($_SESSION['authenticated']) && $_SESSION['authenticated'] === true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customers':
|
||||||
|
requireAuth();
|
||||||
|
if ($method === 'GET') {
|
||||||
|
$customers = loadCustomers();
|
||||||
|
echo json_encode($customers);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer':
|
||||||
|
requireAuth();
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$customers = loadCustomers();
|
||||||
|
$customer = [
|
||||||
|
'id' => generateId(),
|
||||||
|
'yritys' => trim($input['yritys'] ?? ''),
|
||||||
|
'asennusosoite' => trim($input['asennusosoite'] ?? ''),
|
||||||
|
'liittymanopeus' => trim($input['liittymanopeus'] ?? ''),
|
||||||
|
'hinta' => floatval($input['hinta'] ?? 0),
|
||||||
|
'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''),
|
||||||
|
'puhelin' => trim($input['puhelin'] ?? ''),
|
||||||
|
'sahkoposti' => trim($input['sahkoposti'] ?? ''),
|
||||||
|
'laskutusosoite' => trim($input['laskutusosoite'] ?? ''),
|
||||||
|
'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''),
|
||||||
|
'ytunnus' => trim($input['ytunnus'] ?? ''),
|
||||||
|
'lisatiedot' => trim($input['lisatiedot'] ?? ''),
|
||||||
|
'luotu' => date('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
if (empty($customer['yritys'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Yrityksen nimi vaaditaan']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$customers[] = $customer;
|
||||||
|
saveCustomers($customers);
|
||||||
|
echo json_encode($customer);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer_update':
|
||||||
|
requireAuth();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
$customers = loadCustomers();
|
||||||
|
$found = false;
|
||||||
|
foreach ($customers as &$c) {
|
||||||
|
if ($c['id'] === $id) {
|
||||||
|
$c['yritys'] = trim($input['yritys'] ?? $c['yritys']);
|
||||||
|
$c['asennusosoite'] = trim($input['asennusosoite'] ?? $c['asennusosoite']);
|
||||||
|
$c['liittymanopeus'] = trim($input['liittymanopeus'] ?? $c['liittymanopeus']);
|
||||||
|
$c['hinta'] = floatval($input['hinta'] ?? $c['hinta']);
|
||||||
|
$c['yhteyshenkilö'] = trim($input['yhteyshenkilö'] ?? $c['yhteyshenkilö']);
|
||||||
|
$c['puhelin'] = trim($input['puhelin'] ?? $c['puhelin']);
|
||||||
|
$c['sahkoposti'] = trim($input['sahkoposti'] ?? $c['sahkoposti']);
|
||||||
|
$c['laskutusosoite'] = trim($input['laskutusosoite'] ?? $c['laskutusosoite']);
|
||||||
|
$c['laskutussahkoposti'] = trim($input['laskutussahkoposti'] ?? $c['laskutussahkoposti']);
|
||||||
|
$c['ytunnus'] = trim($input['ytunnus'] ?? $c['ytunnus']);
|
||||||
|
$c['lisatiedot'] = trim($input['lisatiedot'] ?? $c['lisatiedot']);
|
||||||
|
$c['muokattu'] = date('Y-m-d H:i:s');
|
||||||
|
$found = true;
|
||||||
|
echo json_encode($c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($c);
|
||||||
|
if (!$found) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Asiakasta ei löydy']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
saveCustomers($customers);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer_delete':
|
||||||
|
requireAuth();
|
||||||
|
if ($method !== 'POST') break;
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$id = $input['id'] ?? '';
|
||||||
|
$customers = loadCustomers();
|
||||||
|
$customers = array_values(array_filter($customers, fn($c) => $c['id'] !== $id));
|
||||||
|
saveCustomers($customers);
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Tuntematon toiminto']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
160
index.html
Normal file
160
index.html
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>CuituNet Intra - Asiakashallinta</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login -->
|
||||||
|
<div id="login-screen" class="login-screen">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>🔒 CuituNet Intra</h1>
|
||||||
|
<p>Kirjaudu sisään</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<input type="password" id="login-password" placeholder="Salasana" required autofocus>
|
||||||
|
<button type="submit">Kirjaudu</button>
|
||||||
|
</form>
|
||||||
|
<div id="login-error" class="error" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<div id="dashboard" style="display:none">
|
||||||
|
<header>
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>CuituNet Intra</h1>
|
||||||
|
<span class="subtitle">Asiakashallinta</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button id="btn-add" class="btn-primary">+ Lisää asiakas</button>
|
||||||
|
<button id="btn-logout" class="btn-secondary">Kirjaudu ulos</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Haku -->
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" id="search-input" placeholder="Hae yrityksen nimellä tai osoitteella...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Taulukko -->
|
||||||
|
<div class="table-container">
|
||||||
|
<table id="customer-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-sort="yritys">Yritys ↕</th>
|
||||||
|
<th data-sort="asennusosoite">Asennusosoite ↕</th>
|
||||||
|
<th data-sort="liittymanopeus">Nopeus ↕</th>
|
||||||
|
<th data-sort="hinta">Hinta/kk ↕</th>
|
||||||
|
<th>Toiminnot</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="customer-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="no-customers" class="empty-state" style="display:none">
|
||||||
|
<p>Ei asiakkaita vielä. Lisää ensimmäinen asiakas!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Yhteenveto -->
|
||||||
|
<div class="summary-bar">
|
||||||
|
<span id="customer-count">0 asiakasta</span>
|
||||||
|
<span id="total-billing">Laskutus yhteensä: 0,00 €/kk</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Asiakas-modal -->
|
||||||
|
<div id="customer-modal" class="modal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modal-title">Lisää asiakas</h2>
|
||||||
|
<button class="modal-close" id="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="customer-form">
|
||||||
|
<input type="hidden" id="form-id">
|
||||||
|
|
||||||
|
<h3>Perustiedot</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-yritys">Yritys *</label>
|
||||||
|
<input type="text" id="form-yritys" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-ytunnus">Y-tunnus</label>
|
||||||
|
<input type="text" id="form-ytunnus" placeholder="1234567-8">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-asennusosoite">Asennusosoite</label>
|
||||||
|
<input type="text" id="form-asennusosoite">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-liittymanopeus">Liittymänopeus</label>
|
||||||
|
<input type="text" id="form-liittymanopeus" placeholder="esim. 100/100">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-hinta">Hinta €/kk</label>
|
||||||
|
<input type="number" id="form-hinta" step="0.01" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Yhteystiedot</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-yhteyshenkilo">Yhteyshenkilö</label>
|
||||||
|
<input type="text" id="form-yhteyshenkilo">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-puhelin">Puhelin</label>
|
||||||
|
<input type="text" id="form-puhelin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-sahkoposti">Sähköposti</label>
|
||||||
|
<input type="email" id="form-sahkoposti">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Laskutustiedot</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label for="form-laskutusosoite">Laskutusosoite</label>
|
||||||
|
<input type="text" id="form-laskutusosoite">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="form-laskutussahkoposti">Laskutussähköposti</label>
|
||||||
|
<input type="email" id="form-laskutussahkoposti">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Lisätiedot</h3>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<textarea id="form-lisatiedot" rows="3" placeholder="Vapaamuotoiset muistiinpanot..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn-primary" id="form-submit">Tallenna</button>
|
||||||
|
<button type="button" class="btn-secondary" id="form-cancel">Peruuta</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tiedot-modal (klikkaa riviä) -->
|
||||||
|
<div id="detail-modal" class="modal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="detail-title">Asiakkaan tiedot</h2>
|
||||||
|
<button class="modal-close" id="detail-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="detail-body"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-primary" id="detail-edit">Muokkaa</button>
|
||||||
|
<button class="btn-danger" id="detail-delete">Poista</button>
|
||||||
|
<button class="btn-secondary" id="detail-cancel">Sulje</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
337
script.js
Normal file
337
script.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
const API = 'api.php';
|
||||||
|
let customers = [];
|
||||||
|
let sortField = 'yritys';
|
||||||
|
let sortAsc = true;
|
||||||
|
let currentDetailId = null;
|
||||||
|
|
||||||
|
// Elements
|
||||||
|
const loginScreen = document.getElementById('login-screen');
|
||||||
|
const dashboard = document.getElementById('dashboard');
|
||||||
|
const loginForm = document.getElementById('login-form');
|
||||||
|
const loginError = document.getElementById('login-error');
|
||||||
|
const searchInput = document.getElementById('search-input');
|
||||||
|
const tbody = document.getElementById('customer-tbody');
|
||||||
|
const noCustomers = document.getElementById('no-customers');
|
||||||
|
const customerCount = document.getElementById('customer-count');
|
||||||
|
const totalBilling = document.getElementById('total-billing');
|
||||||
|
const customerModal = document.getElementById('customer-modal');
|
||||||
|
const detailModal = document.getElementById('detail-modal');
|
||||||
|
const customerForm = document.getElementById('customer-form');
|
||||||
|
|
||||||
|
// API helpers
|
||||||
|
async function apiCall(action, method = 'GET', body = null) {
|
||||||
|
const opts = { method, credentials: 'include' };
|
||||||
|
if (body) {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const res = await fetch(`${API}?action=${action}`, opts);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Virhe');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async function checkAuth() {
|
||||||
|
try {
|
||||||
|
const data = await apiCall('check_auth');
|
||||||
|
if (data.authenticated) {
|
||||||
|
showDashboard();
|
||||||
|
}
|
||||||
|
} catch (e) { /* not logged in */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
try {
|
||||||
|
await apiCall('login', 'POST', { password });
|
||||||
|
loginError.style.display = 'none';
|
||||||
|
showDashboard();
|
||||||
|
} catch (err) {
|
||||||
|
loginError.textContent = err.message;
|
||||||
|
loginError.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
|
await apiCall('logout');
|
||||||
|
dashboard.style.display = 'none';
|
||||||
|
loginScreen.style.display = 'flex';
|
||||||
|
document.getElementById('login-password').value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showDashboard() {
|
||||||
|
loginScreen.style.display = 'none';
|
||||||
|
dashboard.style.display = 'block';
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customers
|
||||||
|
async function loadCustomers() {
|
||||||
|
customers = await apiCall('customers');
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const query = searchInput.value.toLowerCase().trim();
|
||||||
|
let filtered = customers;
|
||||||
|
if (query) {
|
||||||
|
filtered = customers.filter(c =>
|
||||||
|
c.yritys.toLowerCase().includes(query) ||
|
||||||
|
c.asennusosoite.toLowerCase().includes(query) ||
|
||||||
|
(c.yhteyshenkilö && c.yhteyshenkilö.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let va = a[sortField] ?? '';
|
||||||
|
let vb = b[sortField] ?? '';
|
||||||
|
if (sortField === 'hinta') {
|
||||||
|
va = parseFloat(va) || 0;
|
||||||
|
vb = parseFloat(vb) || 0;
|
||||||
|
} else {
|
||||||
|
va = String(va).toLowerCase();
|
||||||
|
vb = String(vb).toLowerCase();
|
||||||
|
}
|
||||||
|
if (va < vb) return sortAsc ? -1 : 1;
|
||||||
|
if (va > vb) return sortAsc ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
noCustomers.style.display = 'block';
|
||||||
|
document.getElementById('customer-table').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
noCustomers.style.display = 'none';
|
||||||
|
document.getElementById('customer-table').style.display = 'table';
|
||||||
|
tbody.innerHTML = filtered.map(c => `
|
||||||
|
<tr data-id="${c.id}">
|
||||||
|
<td>${esc(c.yritys)}</td>
|
||||||
|
<td>${esc(c.asennusosoite)}</td>
|
||||||
|
<td>${esc(c.liittymanopeus)}</td>
|
||||||
|
<td class="price-cell">${formatPrice(c.hinta)}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button onclick="event.stopPropagation(); editCustomer('${c.id}')" title="Muokkaa">✏️</button>
|
||||||
|
<button onclick="event.stopPropagation(); deleteCustomer('${c.id}', '${esc(c.yritys)}')" title="Poista">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSummary(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary(filtered) {
|
||||||
|
const count = customers.length;
|
||||||
|
const total = customers.reduce((sum, c) => sum + (parseFloat(c.hinta) || 0), 0);
|
||||||
|
customerCount.textContent = `${count} asiakasta`;
|
||||||
|
totalBilling.textContent = `Laskutus yhteensä: ${formatPrice(total)}/kk`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(val) {
|
||||||
|
return parseFloat(val || 0).toFixed(2).replace('.', ',') + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
searchInput.addEventListener('input', () => renderTable());
|
||||||
|
|
||||||
|
// Sort
|
||||||
|
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||||||
|
th.addEventListener('click', () => {
|
||||||
|
const field = th.dataset.sort;
|
||||||
|
if (sortField === field) {
|
||||||
|
sortAsc = !sortAsc;
|
||||||
|
} else {
|
||||||
|
sortField = field;
|
||||||
|
sortAsc = true;
|
||||||
|
}
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row click -> detail
|
||||||
|
tbody.addEventListener('click', (e) => {
|
||||||
|
const row = e.target.closest('tr');
|
||||||
|
if (!row) return;
|
||||||
|
const id = row.dataset.id;
|
||||||
|
showDetail(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showDetail(id) {
|
||||||
|
const c = customers.find(x => x.id === id);
|
||||||
|
if (!c) return;
|
||||||
|
currentDetailId = id;
|
||||||
|
|
||||||
|
document.getElementById('detail-title').textContent = c.yritys;
|
||||||
|
document.getElementById('detail-body').innerHTML = `
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Liittymätiedot</h3>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Yritys</div>
|
||||||
|
<div class="detail-value">${esc(c.yritys) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Y-tunnus</div>
|
||||||
|
<div class="detail-value">${esc(c.ytunnus) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Asennusosoite</div>
|
||||||
|
<div class="detail-value">${esc(c.asennusosoite) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Nopeus</div>
|
||||||
|
<div class="detail-value">${esc(c.liittymanopeus) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Hinta</div>
|
||||||
|
<div class="detail-value price-cell">${formatPrice(c.hinta)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Yhteystiedot</h3>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Yhteyshenkilö</div>
|
||||||
|
<div class="detail-value">${esc(c.yhteyshenkilö) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Puhelin</div>
|
||||||
|
<div class="detail-value">${c.puhelin ? `<a href="tel:${esc(c.puhelin)}">${esc(c.puhelin)}</a>` : '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Sähköposti</div>
|
||||||
|
<div class="detail-value">${c.sahkoposti ? `<a href="mailto:${esc(c.sahkoposti)}">${esc(c.sahkoposti)}</a>` : '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Laskutustiedot</h3>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Laskutusosoite</div>
|
||||||
|
<div class="detail-value">${esc(c.laskutusosoite) || '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Laskutussähköposti</div>
|
||||||
|
<div class="detail-value">${c.laskutussahkoposti ? `<a href="mailto:${esc(c.laskutussahkoposti)}">${esc(c.laskutussahkoposti)}</a>` : '<span class="empty">-</span>'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${c.lisatiedot ? `
|
||||||
|
<div class="detail-section">
|
||||||
|
<h3>Lisätiedot</h3>
|
||||||
|
<p style="white-space:pre-wrap; color:#555;">${esc(c.lisatiedot)}</p>
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
detailModal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail modal actions
|
||||||
|
document.getElementById('detail-close').addEventListener('click', () => detailModal.style.display = 'none');
|
||||||
|
document.getElementById('detail-cancel').addEventListener('click', () => detailModal.style.display = 'none');
|
||||||
|
document.getElementById('detail-edit').addEventListener('click', () => {
|
||||||
|
detailModal.style.display = 'none';
|
||||||
|
editCustomer(currentDetailId);
|
||||||
|
});
|
||||||
|
document.getElementById('detail-delete').addEventListener('click', () => {
|
||||||
|
const c = customers.find(x => x.id === currentDetailId);
|
||||||
|
if (c) {
|
||||||
|
detailModal.style.display = 'none';
|
||||||
|
deleteCustomer(currentDetailId, c.yritys);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add/Edit modal
|
||||||
|
document.getElementById('btn-add').addEventListener('click', () => openCustomerForm());
|
||||||
|
document.getElementById('modal-close').addEventListener('click', () => customerModal.style.display = 'none');
|
||||||
|
document.getElementById('form-cancel').addEventListener('click', () => customerModal.style.display = 'none');
|
||||||
|
|
||||||
|
function openCustomerForm(customer = null) {
|
||||||
|
document.getElementById('modal-title').textContent = customer ? 'Muokkaa asiakasta' : 'Lisää asiakas';
|
||||||
|
document.getElementById('form-submit').textContent = customer ? 'Päivitä' : 'Tallenna';
|
||||||
|
document.getElementById('form-id').value = customer ? customer.id : '';
|
||||||
|
document.getElementById('form-yritys').value = customer ? customer.yritys : '';
|
||||||
|
document.getElementById('form-ytunnus').value = customer ? customer.ytunnus : '';
|
||||||
|
document.getElementById('form-asennusosoite').value = customer ? customer.asennusosoite : '';
|
||||||
|
document.getElementById('form-liittymanopeus').value = customer ? customer.liittymanopeus : '';
|
||||||
|
document.getElementById('form-hinta').value = customer ? customer.hinta : '';
|
||||||
|
document.getElementById('form-yhteyshenkilo').value = customer ? customer.yhteyshenkilö : '';
|
||||||
|
document.getElementById('form-puhelin').value = customer ? customer.puhelin : '';
|
||||||
|
document.getElementById('form-sahkoposti').value = customer ? customer.sahkoposti : '';
|
||||||
|
document.getElementById('form-laskutusosoite').value = customer ? customer.laskutusosoite : '';
|
||||||
|
document.getElementById('form-laskutussahkoposti').value = customer ? customer.laskutussahkoposti : '';
|
||||||
|
document.getElementById('form-lisatiedot').value = customer ? customer.lisatiedot : '';
|
||||||
|
customerModal.style.display = 'flex';
|
||||||
|
document.getElementById('form-yritys').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCustomer(id) {
|
||||||
|
const c = customers.find(x => x.id === id);
|
||||||
|
if (c) openCustomerForm(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomer(id, name) {
|
||||||
|
if (!confirm(`Poistetaanko asiakas "${name}"?`)) return;
|
||||||
|
await apiCall('customer_delete', 'POST', { id });
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
|
customerForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('form-id').value;
|
||||||
|
const data = {
|
||||||
|
yritys: document.getElementById('form-yritys').value,
|
||||||
|
ytunnus: document.getElementById('form-ytunnus').value,
|
||||||
|
asennusosoite: document.getElementById('form-asennusosoite').value,
|
||||||
|
liittymanopeus: document.getElementById('form-liittymanopeus').value,
|
||||||
|
hinta: document.getElementById('form-hinta').value,
|
||||||
|
yhteyshenkilö: document.getElementById('form-yhteyshenkilo').value,
|
||||||
|
puhelin: document.getElementById('form-puhelin').value,
|
||||||
|
sahkoposti: document.getElementById('form-sahkoposti').value,
|
||||||
|
laskutusosoite: document.getElementById('form-laskutusosoite').value,
|
||||||
|
laskutussahkoposti: document.getElementById('form-laskutussahkoposti').value,
|
||||||
|
lisatiedot: document.getElementById('form-lisatiedot').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
data.id = id;
|
||||||
|
await apiCall('customer_update', 'POST', data);
|
||||||
|
} else {
|
||||||
|
await apiCall('customer', 'POST', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
customerModal.style.display = 'none';
|
||||||
|
await loadCustomers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modals on backdrop click
|
||||||
|
customerModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === customerModal) customerModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
detailModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === detailModal) detailModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC to close modals
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
customerModal.style.display = 'none';
|
||||||
|
detailModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Init
|
||||||
|
checkAuth();
|
||||||
21
server.py
Normal file
21
server.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PORT = 3001
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"CuituNet Intra käynnistyy osoitteessa http://localhost:{PORT}")
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["php", "-S", f"localhost:{PORT}"],
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nSammutettiin.")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("PHP ei löydy. Asenna PHP ensin.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
474
style.css
Normal file
474
style.css
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login */
|
||||||
|
.login-screen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #0f3460, #16213e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: #fff;
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box p {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box button:hover {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #e74c3c;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
background: #2ecc71;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
border-color: #fff;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e74c3c;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search */
|
||||||
|
.search-bar {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-container {
|
||||||
|
padding: 0 2rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: #16213e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th:hover {
|
||||||
|
background: #1a2744;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-cell {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell button:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #888;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary */
|
||||||
|
.summary-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 2rem;
|
||||||
|
margin: 1rem 2rem 2rem;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#total-billing {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
form {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #0f3460;
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
form h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn-secondary {
|
||||||
|
color: #666;
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn-secondary:hover {
|
||||||
|
color: #333;
|
||||||
|
border-color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail view */
|
||||||
|
#detail-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #0f3460;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1a1a2e;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value.empty {
|
||||||
|
color: #ccc;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid,
|
||||||
|
.detail-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-bar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-bar {
|
||||||
|
margin: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th,
|
||||||
|
tbody td {
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user