Sekut Bakery 1

This commit is contained in:
Flatlogic Bot 2026-05-26 07:35:51 +00:00
parent d282dac2e9
commit 30e21c6f6f
6 changed files with 1021 additions and 1 deletions

974
admin.php Normal file
View File

@ -0,0 +1,974 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/store.php';
const STORE_ADMIN_SESSION_KEY = 'sekut_admin_user';
const STORE_ADMIN_CSRF_KEY = 'sekut_admin_csrf';
function admin_ensure_auth_schema(): void
{
static $ready = false;
if ($ready) {
return;
}
$migrationPath = __DIR__ . '/db/migrations/20260526_create_admin_users.sql';
if (is_file($migrationPath)) {
$sql = file_get_contents($migrationPath);
if (is_string($sql) && trim($sql) !== '') {
db()->exec($sql);
$ready = true;
return;
}
}
db()->exec(
"CREATE TABLE IF NOT EXISTS admin_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_login_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_admin_email (email),
INDEX idx_admin_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$ready = true;
}
function admin_has_users(): bool
{
admin_ensure_auth_schema();
$stmt = db()->query('SELECT COUNT(*) AS total FROM admin_users');
$row = $stmt->fetch() ?: [];
return (int)($row['total'] ?? 0) > 0;
}
function admin_current_user(): ?array
{
$sessionUser = $_SESSION[STORE_ADMIN_SESSION_KEY] ?? null;
if (!is_array($sessionUser)) {
return null;
}
$id = (int)($sessionUser['id'] ?? 0);
$fullName = store_sanitize_line((string)($sessionUser['full_name'] ?? ''), 120);
$email = store_lower(trim((string)($sessionUser['email'] ?? '')));
if ($id <= 0 || $fullName === '' || $email === '') {
unset($_SESSION[STORE_ADMIN_SESSION_KEY]);
return null;
}
return [
'id' => $id,
'full_name' => $fullName,
'email' => $email,
];
}
function admin_store_session(array $user): void
{
$_SESSION[STORE_ADMIN_SESSION_KEY] = [
'id' => (int)($user['id'] ?? 0),
'full_name' => store_sanitize_line((string)($user['full_name'] ?? ''), 120),
'email' => store_lower(trim((string)($user['email'] ?? ''))),
];
session_regenerate_id(true);
}
function admin_logout(): void
{
unset($_SESSION[STORE_ADMIN_SESSION_KEY]);
session_regenerate_id(true);
}
function admin_is_authenticated(): bool
{
return admin_current_user() !== null;
}
function admin_csrf_token(): string
{
$token = $_SESSION[STORE_ADMIN_CSRF_KEY] ?? '';
if (!is_string($token) || $token === '') {
try {
$token = bin2hex(random_bytes(32));
} catch (Throwable $exception) {
$token = sha1(session_id() . '|' . microtime(true));
}
$_SESSION[STORE_ADMIN_CSRF_KEY] = $token;
}
return $token;
}
function admin_verify_csrf(?string $token): bool
{
if (!is_string($token) || $token === '') {
return false;
}
return hash_equals(admin_csrf_token(), $token);
}
function admin_auth_form_defaults(array $source = []): array
{
return [
'full_name' => store_sanitize_line((string)($source['full_name'] ?? ''), 120),
'email' => store_lower(trim((string)($source['email'] ?? ''))),
];
}
function admin_password_error(string $password): string
{
if (store_strlen($password) < 8) {
return 'Password admin minimal 8 karakter.';
}
return '';
}
function admin_create_first_user(array $source): array
{
admin_ensure_auth_schema();
$form = admin_auth_form_defaults($source);
$password = (string)($source['password'] ?? '');
$confirmPassword = (string)($source['confirm_password'] ?? '');
if (admin_has_users()) {
return [
'success' => false,
'message' => 'Akun admin sudah dibuat. Silakan login dengan email dan password admin.',
'form' => ['full_name' => '', 'email' => $form['email']],
];
}
if (store_strlen($form['full_name']) < 3) {
return [
'success' => false,
'message' => 'Nama admin minimal 3 karakter.',
'form' => $form,
];
}
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL)) {
return [
'success' => false,
'message' => 'Masukkan email admin yang valid.',
'form' => $form,
];
}
$passwordError = admin_password_error($password);
if ($passwordError !== '') {
return [
'success' => false,
'message' => $passwordError,
'form' => $form,
];
}
if ($password !== $confirmPassword) {
return [
'success' => false,
'message' => 'Konfirmasi password admin belum cocok.',
'form' => $form,
];
}
$stmt = db()->prepare(
'INSERT INTO admin_users (full_name, email, password_hash, last_login_at) VALUES (:full_name, :email, :password_hash, NOW())'
);
$stmt->bindValue(':full_name', $form['full_name']);
$stmt->bindValue(':email', $form['email']);
$stmt->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
$stmt->execute();
$user = [
'id' => (int)db()->lastInsertId(),
'full_name' => $form['full_name'],
'email' => $form['email'],
];
admin_store_session($user);
return [
'success' => true,
'message' => 'Akun admin pertama berhasil dibuat dan sesi login sudah aktif.',
'form' => ['full_name' => '', 'email' => ''],
];
}
function admin_attempt_login(array $source): array
{
admin_ensure_auth_schema();
$form = admin_auth_form_defaults($source);
$password = (string)($source['password'] ?? '');
if (!admin_has_users()) {
return [
'success' => false,
'message' => 'Belum ada akun admin. Buat akun admin pertama terlebih dahulu.',
'form' => $form,
];
}
if (!filter_var($form['email'], FILTER_VALIDATE_EMAIL) || $password === '') {
return [
'success' => false,
'message' => 'Masukkan email admin dan password yang benar.',
'form' => $form,
];
}
$stmt = db()->prepare('SELECT id, full_name, email, password_hash FROM admin_users WHERE email = :email LIMIT 1');
$stmt->bindValue(':email', $form['email']);
$stmt->execute();
$user = $stmt->fetch();
if (!$user || !password_verify($password, (string)($user['password_hash'] ?? ''))) {
return [
'success' => false,
'message' => 'Email admin atau password tidak cocok.',
'form' => $form,
];
}
$update = db()->prepare('UPDATE admin_users SET last_login_at = NOW() WHERE id = :id LIMIT 1');
$update->bindValue(':id', (int)($user['id'] ?? 0), PDO::PARAM_INT);
$update->execute();
if (password_needs_rehash((string)($user['password_hash'] ?? ''), PASSWORD_DEFAULT)) {
$rehash = db()->prepare('UPDATE admin_users SET password_hash = :password_hash WHERE id = :id LIMIT 1');
$rehash->bindValue(':password_hash', password_hash($password, PASSWORD_DEFAULT));
$rehash->bindValue(':id', (int)($user['id'] ?? 0), PDO::PARAM_INT);
$rehash->execute();
}
admin_store_session($user);
return [
'success' => true,
'message' => 'Login admin berhasil.',
'form' => ['full_name' => '', 'email' => ''],
];
}
function admin_status_options(): array
{
$options = [];
foreach (store_status_steps() as $step) {
$value = (string)($step['value'] ?? '');
$label = (string)($step['label'] ?? $value);
if ($value !== '') {
$options[$value] = $label;
}
}
$options['Batal'] = 'Batal';
return $options;
}
function admin_filters_from_source(array $source): array
{
$statuses = admin_status_options();
$search = store_sanitize_line((string)($source['search'] ?? ''), 120);
$status = store_sanitize_line((string)($source['status'] ?? 'all'), 40);
$selected = store_sanitize_line((string)($source['selected'] ?? ''), 30);
if ($status !== 'all' && !isset($statuses[$status])) {
$status = 'all';
}
return [
'search' => $search,
'status' => $status,
'selected' => $selected,
];
}
function admin_url(array $filters, array $overrides = []): string
{
$params = $filters;
foreach ($overrides as $key => $value) {
if (is_string($key) && is_string($value)) {
$params[$key] = $value;
}
}
$query = [];
foreach ($params as $key => $value) {
if (!is_string($key) || !is_string($value) || $value === '') {
continue;
}
if ($key === 'status' && $value === 'all') {
continue;
}
$query[$key] = $value;
}
return 'admin.php' . ($query ? '?' . http_build_query($query) : '');
}
function admin_order_dashboard(): array
{
store_ensure_schema();
$stmt = db()->query(
"SELECT
COUNT(*) AS total_orders,
COALESCE(SUM(CASE WHEN status = 'Menunggu Pembayaran' THEN 1 ELSE 0 END), 0) AS pending_orders,
COALESCE(SUM(CASE WHEN status IN ('Diproses', 'Dikirim') THEN 1 ELSE 0 END), 0) AS active_orders,
COALESCE(SUM(CASE WHEN status = 'Selesai' THEN 1 ELSE 0 END), 0) AS completed_orders,
COALESCE(SUM(grand_total), 0) AS gross_revenue,
COALESCE(SUM(CASE WHEN created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 ELSE 0 END), 0) AS recent_orders
FROM orders"
);
$row = $stmt->fetch() ?: [];
return [
'total_orders' => (int)($row['total_orders'] ?? 0),
'pending_orders' => (int)($row['pending_orders'] ?? 0),
'active_orders' => (int)($row['active_orders'] ?? 0),
'completed_orders' => (int)($row['completed_orders'] ?? 0),
'gross_revenue' => (float)($row['gross_revenue'] ?? 0),
'recent_orders' => (int)($row['recent_orders'] ?? 0),
];
}
function admin_list_orders(array $filters): array
{
store_ensure_schema();
$conditions = [];
$params = [];
if (($filters['search'] ?? '') !== '') {
$conditions[] = '(order_number LIKE :search OR customer_name LIKE :search OR email LIKE :search OR phone LIKE :search)';
$params['search'] = '%' . $filters['search'] . '%';
}
if (($filters['status'] ?? 'all') !== 'all') {
$conditions[] = 'status = :status';
$params['status'] = $filters['status'];
}
$sql = 'SELECT order_number, customer_name, email, phone, status, grand_total, payment_method, items_json, created_at, updated_at FROM orders';
if ($conditions) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= ' ORDER BY created_at DESC LIMIT 100';
$stmt = db()->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->execute();
$methods = store_payment_methods();
$orders = $stmt->fetchAll() ?: [];
foreach ($orders as &$order) {
$items = json_decode((string)($order['items_json'] ?? '[]'), true);
$itemCount = 0;
if (is_array($items)) {
foreach ($items as $item) {
if (is_array($item)) {
$itemCount += (int)($item['quantity'] ?? 0);
}
}
}
$methodKey = (string)($order['payment_method'] ?? '');
$order['payment_method_label'] = $methods[$methodKey]['label'] ?? $methodKey;
$order['item_count'] = $itemCount;
}
unset($order);
return $orders;
}
function admin_update_order_status(string $orderNumber, string $status): array
{
$orderNumber = store_sanitize_line($orderNumber, 30);
$status = store_sanitize_line($status, 40);
$options = admin_status_options();
if ($orderNumber === '') {
return ['success' => false, 'message' => 'Kode pesanan tidak valid.'];
}
if (!isset($options[$status])) {
return ['success' => false, 'message' => 'Status pesanan tidak dikenali.'];
}
$existing = store_find_order($orderNumber);
if (!$existing) {
return ['success' => false, 'message' => 'Pesanan tidak ditemukan.'];
}
if ((string)$existing['status'] === $status) {
return ['success' => true, 'message' => 'Status pesanan sudah berada di tahap ' . $options[$status] . '.'];
}
$stmt = db()->prepare('UPDATE orders SET status = :status WHERE order_number = :order_number LIMIT 1');
$stmt->bindValue(':status', $status);
$stmt->bindValue(':order_number', $orderNumber);
$stmt->execute();
return [
'success' => true,
'message' => 'Status pesanan ' . $orderNumber . ' diubah ke ' . $options[$status] . '.',
];
}
function admin_catalog_snapshot(): array
{
$categories = store_categories();
$snapshot = [];
foreach ($categories as $slug => $category) {
if ($slug === 'all') {
continue;
}
$snapshot[$slug] = [
'slug' => $slug,
'label' => (string)($category['label'] ?? $slug),
'description' => (string)($category['description'] ?? ''),
'count' => 0,
'min_price' => null,
'max_price' => null,
'products' => [],
];
}
foreach (store_products() as $product) {
$categoryKey = (string)($product['category'] ?? '');
if (!isset($snapshot[$categoryKey])) {
continue;
}
$price = (float)($product['price'] ?? 0);
$snapshot[$categoryKey]['count']++;
$snapshot[$categoryKey]['min_price'] = $snapshot[$categoryKey]['min_price'] === null
? $price
: min((float)$snapshot[$categoryKey]['min_price'], $price);
$snapshot[$categoryKey]['max_price'] = $snapshot[$categoryKey]['max_price'] === null
? $price
: max((float)$snapshot[$categoryKey]['max_price'], $price);
$snapshot[$categoryKey]['products'][] = [
'name' => (string)($product['name'] ?? ''),
'price' => $price,
'lead_time' => (string)($product['lead_time'] ?? ''),
'slug' => (string)($product['slug'] ?? ''),
];
}
return array_values($snapshot);
}
$authForm = admin_auth_form_defaults();
$authError = '';
$filters = admin_filters_from_source($_GET);
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$action = store_sanitize_line((string)($_POST['action'] ?? ''), 40);
if (!admin_verify_csrf((string)($_POST['csrf_token'] ?? ''))) {
if ($action === 'setup_admin' || $action === 'login_admin') {
$authForm = admin_auth_form_defaults($_POST);
$authError = 'Sesi keamanan sudah berakhir. Muat ulang halaman admin lalu coba lagi.';
} else {
store_flash('danger', 'Sesi keamanan admin sudah berakhir. Coba ulangi tindakannya.');
header('Location: admin.php');
exit;
}
} elseif ($action === 'logout_admin') {
admin_logout();
store_flash('success', 'Sesi admin berhasil ditutup.');
header('Location: admin.php');
exit;
} elseif ($action === 'setup_admin') {
$result = admin_create_first_user($_POST);
if (!empty($result['success'])) {
store_flash('success', (string)($result['message'] ?? 'Akun admin berhasil dibuat.'));
header('Location: admin.php');
exit;
}
$authForm = is_array($result['form'] ?? null) ? $result['form'] : admin_auth_form_defaults($_POST);
$authError = (string)($result['message'] ?? 'Setup admin gagal diproses.');
} elseif ($action === 'login_admin') {
$result = admin_attempt_login($_POST);
if (!empty($result['success'])) {
store_flash('success', (string)($result['message'] ?? 'Login admin berhasil.'));
header('Location: admin.php');
exit;
}
$authForm = is_array($result['form'] ?? null) ? $result['form'] : admin_auth_form_defaults($_POST);
$authError = (string)($result['message'] ?? 'Login admin gagal diproses.');
} elseif ($action === 'update_status') {
if (!admin_is_authenticated()) {
store_flash('danger', 'Silakan login admin terlebih dahulu.');
header('Location: admin.php');
exit;
}
$filters = admin_filters_from_source($_POST);
$result = admin_update_order_status(
(string)($_POST['order_number'] ?? ''),
(string)($_POST['status_value'] ?? '')
);
store_flash(!empty($result['success']) ? 'success' : 'danger', (string)($result['message'] ?? 'Aksi admin gagal diproses.'));
header('Location: ' . admin_url($filters, ['selected' => (string)($_POST['order_number'] ?? '')]));
exit;
} else {
store_flash('danger', 'Aksi admin tidak dikenali.');
header('Location: admin.php');
exit;
}
}
$adminUser = admin_current_user();
if (!$adminUser) {
$hasAdminAccount = admin_has_users();
store_page_start(
$hasAdminAccount ? 'Login Admin' : 'Setup Admin',
$hasAdminAccount
? 'Masuk ke dashboard admin untuk memantau dan memperbarui pesanan bakery.'
: 'Buat akun admin pertama untuk melindungi dashboard operasional bakery.',
['noindex' => true]
);
?>
<section class="hero-panel mb-4 mb-lg-5">
<div class="row g-4 align-items-stretch">
<div class="col-lg-6">
<span class="eyebrow">Admin access</span>
<h1 class="display-title"><?= $hasAdminAccount ? 'Masuk ke dashboard internal yang sudah dilindungi.' : 'Aktifkan login admin sebelum panel dipakai rutin.' ?></h1>
<p class="lead-copy mb-0">
<?= $hasAdminAccount
? 'Dashboard order sekarang butuh sesi login, jadi update status pesanan tidak lagi terbuka untuk publik.'
: 'Langkah setup ini hanya muncul sekali untuk membuat akun admin pertama. Setelah tersimpan, halaman otomatis berubah jadi form login.' ?>
</p>
<?php if ($authError !== ''): ?>
<div class="alert alert-danger mt-4 mb-0 border-0 shadow-sm" role="alert"><?= h($authError) ?></div>
<?php else: ?>
<div class="alert alert-info mt-4 mb-0 border-0 shadow-sm" role="alert">
<strong><?= $hasAdminAccount ? 'Akses aman:' : 'Setup sekali:' ?></strong>
<?= $hasAdminAccount
? 'gunakan email admin dan password yang sudah dibuat untuk membuka dashboard.'
: 'buat email admin internal dan password minimal 8 karakter agar halaman admin tidak lagi terbuka bebas.' ?>
</div>
<?php endif; ?>
</div>
<div class="col-lg-6">
<aside class="summary-card h-100">
<div class="card-kicker"><?= $hasAdminAccount ? 'Login required' : 'Setup pertama' ?></div>
<h2 class="summary-title"><?= $hasAdminAccount ? 'Masukkan email dan password admin.' : 'Buat akun admin internal untuk toko ini.' ?></h2>
<form action="admin.php" method="post" class="mt-4" data-auto-disable>
<input type="hidden" name="action" value="<?= $hasAdminAccount ? 'login_admin' : 'setup_admin' ?>">
<input type="hidden" name="csrf_token" value="<?= h(admin_csrf_token()) ?>">
<?php if (!$hasAdminAccount): ?>
<div class="mb-3">
<label class="form-label" for="full_name">Nama admin</label>
<input id="full_name" class="form-control" type="text" name="full_name" value="<?= h($authForm['full_name']) ?>" maxlength="120" autocomplete="name" placeholder="Contoh: Sekut Ops" required>
</div>
<?php endif; ?>
<div class="mb-3">
<label class="form-label" for="email">Email admin</label>
<input id="email" class="form-control" type="email" name="email" value="<?= h($authForm['email']) ?>" maxlength="160" autocomplete="username" placeholder="admin@sekutbakery.test" required>
</div>
<div class="mb-3">
<label class="form-label" for="password"><?= $hasAdminAccount ? 'Password' : 'Password admin' ?></label>
<input id="password" class="form-control" type="password" name="password" minlength="8" autocomplete="<?= $hasAdminAccount ? 'current-password' : 'new-password' ?>" placeholder="Minimal 8 karakter" required>
</div>
<?php if (!$hasAdminAccount): ?>
<div class="mb-4">
<label class="form-label" for="confirm_password">Konfirmasi password</label>
<input id="confirm_password" class="form-control" type="password" name="confirm_password" minlength="8" autocomplete="new-password" placeholder="Ulangi password admin" required>
</div>
<?php endif; ?>
<div class="d-grid gap-2">
<button class="btn btn-dark" type="submit"><?= $hasAdminAccount ? 'Masuk ke admin' : 'Buat akun admin' ?></button>
<a class="btn btn-outline-secondary" href="index.php">Kembali ke toko</a>
</div>
</form>
<ul class="list-clean compact-list mt-4 mb-0">
<?php if ($hasAdminAccount): ?>
<li><span class="list-index">1</span><span>Gunakan email admin yang dibuat saat setup pertama.</span></li>
<li><span class="list-index">2</span><span>Password admin disimpan sebagai hash aman di MySQL.</span></li>
<li><span class="list-index">3</span><span>Setelah login, kamu bisa lanjut mengubah status order dari dashboard.</span></li>
<?php else: ?>
<li><span class="list-index">1</span><span>Buat satu akun admin internal dengan email yang aktif.</span></li>
<li><span class="list-index">2</span><span>Gunakan password minimal 8 karakter agar panel tidak lagi terbuka publik.</span></li>
<li><span class="list-index">3</span><span>Sesudah akun pertama tersimpan, halaman ini otomatis berubah jadi form login.</span></li>
<?php endif; ?>
</ul>
</aside>
</div>
</div>
</section>
<?php
store_page_end();
exit;
}
$dashboard = admin_order_dashboard();
$orders = admin_list_orders($filters);
$selectedOrderNumber = $filters['selected'];
if ($selectedOrderNumber === '' && !empty($orders[0]['order_number'])) {
$selectedOrderNumber = (string)$orders[0]['order_number'];
}
$selectedOrder = $selectedOrderNumber !== '' ? store_find_order($selectedOrderNumber) : null;
$catalogSnapshot = admin_catalog_snapshot();
$statusOptions = admin_status_options();
store_page_start(
'Admin Lite',
'Dashboard internal untuk memantau pesanan bakery, melihat ringkasan katalog, dan mengubah status order.',
['noindex' => true]
);
?>
<section class="hero-panel mb-4 mb-lg-5">
<div class="row g-4 align-items-start">
<div class="col-lg-7">
<span class="eyebrow">Internal admin</span>
<h1 class="display-title">Pantau order masuk tanpa panel yang berat.</h1>
<p class="lead-copy mb-0">
Slice ini fokus ke kebutuhan operasional paling dekat: melihat antrean pesanan, membuka detail customer,
lalu mengubah status order saat pembayaran masuk, produksi dimulai, atau pengiriman selesai.
</p>
<div class="alert alert-success mt-4 mb-0 border-0 shadow-sm" role="alert">
<strong>Akses aman aktif:</strong> dashboard ini sekarang memerlukan login admin.
Sesi aktif untuk <strong><?= h($adminUser['full_name']) ?></strong> (<?= h($adminUser['email']) ?>).
</div>
</div>
<div class="col-lg-5">
<aside class="summary-card h-100">
<div class="card-kicker">Ruang lingkup iterasi ini</div>
<h2 class="summary-title">Order management dulu, catalog CRUD nanti.</h2>
<ul class="list-clean compact-list mb-4">
<li><span class="list-index">1</span><span>Dashboard sekarang dilindungi login berbasis session.</span></li>
<li><span class="list-index">2</span><span>Status order bisa diubah dari dashboard admin.</span></li>
<li><span class="list-index">3</span><span>Produk dan kategori tampil sebagai snapshot read-only.</span></li>
</ul>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-dark" href="index.php">Kembali ke toko</a>
<?php if ($selectedOrder): ?>
<a class="btn btn-outline-secondary" href="<?= h('order_status.php?order=' . urlencode((string)$selectedOrder['order_number']) . '&email=' . urlencode((string)$selectedOrder['email'])) ?>">Lihat halaman customer</a>
<?php endif; ?>
<form action="admin.php" method="post" class="d-inline">
<input type="hidden" name="action" value="logout_admin">
<input type="hidden" name="csrf_token" value="<?= h(admin_csrf_token()) ?>">
<button class="btn btn-outline-danger" type="submit">Logout</button>
</form>
</div>
<div class="small text-muted mt-3">Login sebagai <?= h($adminUser['full_name']) ?> • <?= h($adminUser['email']) ?></div>
</aside>
</div>
</div>
</section>
<section class="mb-4 mb-lg-5">
<div class="row g-3">
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-value"><?= h((string)$dashboard['total_orders']) ?></div>
<div class="metric-label">total pesanan tersimpan</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-value"><?= h((string)$dashboard['pending_orders']) ?></div>
<div class="metric-label">menunggu pembayaran</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-value"><?= h((string)$dashboard['active_orders']) ?></div>
<div class="metric-label">sedang diproses / dikirim</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="metric-card h-100">
<div class="metric-value metric-value--small"><?= h(store_money((float)$dashboard['gross_revenue'])) ?></div>
<div class="metric-label">gross value seluruh order</div>
</div>
</div>
</div>
</section>
<section class="section-block pt-0">
<div class="surface-panel p-4 mb-4">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-end">
<div>
<div class="card-kicker">Filter antrean</div>
<h2 class="summary-title mb-1">Cari order yang perlu ditindak.</h2>
<p class="section-copy mb-0">Gunakan pencarian berdasarkan kode order, nama customer, email, atau telepon.</p>
</div>
<div class="text-muted small">Pesanan 7 hari terakhir: <strong class="text-dark"><?= h((string)$dashboard['recent_orders']) ?></strong></div>
</div>
<form action="admin.php" method="get" class="row g-3 mt-1">
<div class="col-lg-7">
<label class="form-label" for="search">Cari pesanan</label>
<input id="search" class="form-control" type="search" name="search" value="<?= h($filters['search']) ?>" placeholder="Contoh: SB260526 atau nama customer">
</div>
<div class="col-lg-3">
<label class="form-label" for="status">Filter status</label>
<select id="status" class="form-control" name="status">
<option value="all">Semua status</option>
<?php foreach ($statusOptions as $value => $label): ?>
<option value="<?= h($value) ?>" <?= $filters['status'] === $value ? 'selected' : '' ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-lg-2 d-flex align-items-end gap-2">
<button class="btn btn-dark w-100" type="submit">Terapkan</button>
</div>
</form>
</div>
<div class="row g-4 align-items-start">
<div class="col-xl-7">
<?php if (!$orders): ?>
<div class="empty-state-card">
<span class="eyebrow">Belum ada hasil</span>
<h2 class="section-title">Tidak ada pesanan yang cocok dengan filter ini.</h2>
<p class="section-copy mb-0">Coba reset pencarian atau ubah status filter untuk melihat antrean yang lain.</p>
</div>
<?php else: ?>
<div class="surface-panel p-0 overflow-hidden">
<div class="p-4 border-bottom">
<div class="card-kicker">Order queue</div>
<h2 class="summary-title mb-1"><?= h((string)count($orders)) ?> pesanan tampil di dashboard.</h2>
<p class="section-copy mb-0">Klik detail untuk membuka panel ringkasan dan mengubah status order.</p>
</div>
<div class="table-responsive">
<table class="table order-table align-middle mb-0">
<thead>
<tr>
<th scope="col">Pesanan</th>
<th scope="col">Customer</th>
<th scope="col">Status</th>
<th scope="col" class="text-end">Total</th>
<th scope="col" class="text-end">Aksi</th>
</tr>
</thead>
<tbody>
<?php foreach ($orders as $order): ?>
<tr class="<?= $selectedOrderNumber === (string)$order['order_number'] ? 'table-active' : '' ?>">
<td>
<strong><?= h((string)$order['order_number']) ?></strong>
<div class="small text-muted"><?= h((string)$order['item_count']) ?> item • <?= h((string)$order['payment_method_label']) ?></div>
</td>
<td>
<strong><?= h((string)$order['customer_name']) ?></strong>
<div class="small text-muted"><?= h((string)$order['email']) ?></div>
<div class="small text-muted"><?= h((string)$order['phone']) ?></div>
</td>
<td>
<span class="<?= h(store_status_class((string)$order['status'])) ?>"><?= h((string)$order['status']) ?></span>
</td>
<td class="text-end">
<strong><?= h(store_money((float)$order['grand_total'])) ?></strong>
<div class="small text-muted"><?= h(store_format_datetime((string)$order['created_at'])) ?></div>
</td>
<td class="text-end">
<a class="btn btn-outline-secondary btn-sm" href="<?= h(admin_url($filters, ['selected' => (string)$order['order_number']])) ?>">Detail</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>
</div>
<div class="col-xl-5">
<?php if ($selectedOrder): ?>
<aside class="summary-card">
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-start">
<div>
<div class="card-kicker">Pesanan dipilih</div>
<h2 class="summary-title mb-1"><?= h((string)$selectedOrder['order_number']) ?></h2>
<p class="text-muted mb-0">Dibuat pada <?= h(store_format_datetime((string)$selectedOrder['created_at'])) ?></p>
</div>
<span class="<?= h(store_status_class((string)$selectedOrder['status'])) ?>"><?= h((string)$selectedOrder['status']) ?></span>
</div>
<form action="admin.php" method="post" class="mt-4" data-auto-disable>
<input type="hidden" name="action" value="update_status">
<input type="hidden" name="csrf_token" value="<?= h(admin_csrf_token()) ?>">
<input type="hidden" name="order_number" value="<?= h((string)$selectedOrder['order_number']) ?>">
<input type="hidden" name="search" value="<?= h($filters['search']) ?>">
<input type="hidden" name="status" value="<?= h($filters['status']) ?>">
<input type="hidden" name="selected" value="<?= h((string)$selectedOrder['order_number']) ?>">
<label class="form-label" for="status_value">Ubah status pesanan</label>
<div class="d-grid gap-2">
<select id="status_value" class="form-control" name="status_value">
<?php foreach ($statusOptions as $value => $label): ?>
<option value="<?= h($value) ?>" <?= (string)$selectedOrder['status'] === $value ? 'selected' : '' ?>><?= h($label) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-dark" type="submit">Simpan status</button>
</div>
</form>
<div class="info-box mt-4">
<div class="card-kicker">Data customer</div>
<p class="mb-1"><strong><?= h((string)$selectedOrder['customer_name']) ?></strong></p>
<p class="mb-1"><?= h((string)$selectedOrder['email']) ?></p>
<p class="mb-1"><?= h((string)$selectedOrder['phone']) ?></p>
<p class="mb-0"><?= nl2br(h((string)$selectedOrder['address'])) ?></p>
</div>
<div class="info-box mt-3">
<div class="card-kicker">Pembayaran & catatan</div>
<p class="mb-1"><strong><?= h((string)$selectedOrder['payment_method_label']) ?></strong></p>
<p class="mb-0"><?= h((string)$selectedOrder['payment_instruction']) ?></p>
<?php if (!empty($selectedOrder['note'])): ?>
<div class="note-copy mt-3"><strong>Catatan customer:</strong> <?= h((string)$selectedOrder['note']) ?></div>
<?php endif; ?>
</div>
<div class="surface-panel p-4 mt-3">
<div class="card-kicker">Ringkasan item</div>
<div class="receipt-card receipt-card--flat">
<?php foreach ($selectedOrder['items'] as $item): ?>
<div class="receipt-line">
<span><?= h((string)$item['name']) ?> × <?= h((string)$item['quantity']) ?></span>
<strong><?= h(store_money((float)$item['line_total'])) ?></strong>
</div>
<?php endforeach; ?>
<div class="receipt-line">
<span>Subtotal</span>
<strong><?= h(store_money((float)$selectedOrder['subtotal'])) ?></strong>
</div>
<div class="receipt-line">
<span>Ongkir</span>
<strong><?= h(store_money((float)$selectedOrder['shipping_fee'])) ?></strong>
</div>
<div class="receipt-line receipt-line--total">
<span>Grand total</span>
<strong><?= h(store_money((float)$selectedOrder['grand_total'])) ?></strong>
</div>
</div>
</div>
<?php if ((string)$selectedOrder['status'] !== 'Batal'): ?>
<?php $currentIndex = store_status_index((string)$selectedOrder['status']); ?>
<div class="surface-panel p-4 mt-3">
<div class="card-kicker">Progress customer-facing</div>
<div class="timeline-list mt-3">
<?php foreach (store_status_steps() as $index => $step): ?>
<?php
$stateClass = 'timeline-step';
if ($index < $currentIndex) {
$stateClass .= ' is-complete';
} elseif ($index === $currentIndex) {
$stateClass .= ' is-current';
}
?>
<div class="<?= h($stateClass) ?>">
<div class="timeline-step__dot"></div>
<div>
<div class="timeline-step__title"><?= h((string)$step['label']) ?></div>
<p class="timeline-step__copy mb-0"><?= h((string)$step['description']) ?></p>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="info-box mt-3 mb-0">
<div class="card-kicker">Status batal</div>
<p class="mb-0">Pesanan ini sudah dibatalkan, jadi timeline customer tidak lagi berjalan.</p>
</div>
<?php endif; ?>
</aside>
<?php else: ?>
<div class="empty-state-card">
<span class="eyebrow">Pilih pesanan</span>
<h2 class="section-title">Belum ada order yang dibuka.</h2>
<p class="section-copy mb-0">Klik tombol detail pada tabel agar admin bisa melihat item, data customer, dan mengganti status pesanan.</p>
</div>
<?php endif; ?>
</div>
</div>
</section>
<section class="section-block pt-0">
<div class="section-heading mb-4">
<span class="eyebrow">Catalog snapshot</span>
<h2 class="section-title">Produk dan kategori tampil sebagai referensi operasional.</h2>
<p class="section-copy mb-0">Di slice ini catalog belum editable dari admin. Fokusnya masih order handling agar alur transaksi sudah bisa dijalankan dulu.</p>
</div>
<div class="row g-3 mb-4">
<?php foreach ($catalogSnapshot as $category): ?>
<div class="col-md-6 col-xl-4">
<div class="feature-card h-100">
<div class="card-kicker"><?= h((string)$category['label']) ?></div>
<div class="metric-value"><?= h((string)$category['count']) ?></div>
<div class="metric-label">produk aktif di kategori ini</div>
<p class="feature-card__copy mt-3 mb-2"><?= h((string)$category['description']) ?></p>
<?php if ($category['min_price'] !== null && $category['max_price'] !== null): ?>
<div class="price-tag"><?= h(store_money((float)$category['min_price'])) ?> — <?= h(store_money((float)$category['max_price'])) ?></div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="surface-panel p-0 overflow-hidden">
<div class="p-4 border-bottom">
<div class="card-kicker">Produk aktif</div>
<h2 class="summary-title mb-1"><?= h((string)count(store_products())) ?> produk ada di katalog saat ini.</h2>
<p class="section-copy mb-0">Langkah berikutnya bila dibutuhkan: pindahkan katalog ke tabel DB agar admin bisa menambah/edit produk dari UI.</p>
</div>
<div class="table-responsive">
<table class="table order-table align-middle mb-0">
<thead>
<tr>
<th scope="col">Produk</th>
<th scope="col">Kategori</th>
<th scope="col">Lead time</th>
<th scope="col" class="text-end">Harga</th>
</tr>
</thead>
<tbody>
<?php foreach (store_products() as $product): ?>
<tr>
<td>
<strong><?= h((string)$product['name']) ?></strong>
<div class="small text-muted"><?= h((string)$product['slug']) ?></div>
</td>
<td><?= h((string)$product['category_label']) ?></td>
<td><?= h((string)$product['lead_time']) ?></td>
<td class="text-end"><strong><?= h(store_money((float)$product['price'])) ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</section>
<?php store_page_end();

21
assets/images/favicon.svg Normal file
View File

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title desc">
<title id="title">Sekut Bakery favicon</title>
<desc id="desc">Rounded square bakery icon with SB monogram.</desc>
<defs>
<linearGradient id="bg" x1="16" y1="12" x2="112" y2="116" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#3B3531"/>
<stop offset="1" stop-color="#B07B4F"/>
</linearGradient>
<linearGradient id="glow" x1="26" y1="28" x2="102" y2="102" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#F5E9D8" stop-opacity="0.95"/>
<stop offset="1" stop-color="#F8F3EC" stop-opacity="0.65"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="url(#bg)"/>
<circle cx="64" cy="64" r="39" fill="url(#glow)" opacity="0.92"/>
<path d="M35 74c5-14 18-23 29-23 14 0 27 9 31 23" fill="none" stroke="#8B5E3C" stroke-linecap="round" stroke-width="6"/>
<circle cx="49" cy="52" r="4" fill="#8B5E3C" opacity="0.75"/>
<circle cx="65" cy="46" r="4" fill="#8B5E3C" opacity="0.75"/>
<circle cx="80" cy="53" r="4" fill="#8B5E3C" opacity="0.75"/>
<text x="64" y="86" text-anchor="middle" font-size="28" font-family="Inter, Arial, sans-serif" font-weight="800" fill="#2B2927">SB</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,12 @@
/* Admin auth table for the Sekut Bakery dashboard. */
CREATE TABLE IF NOT EXISTS admin_users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
full_name VARCHAR(120) NOT NULL,
email VARCHAR(160) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
last_login_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_admin_email (email),
INDEX idx_admin_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -207,7 +207,7 @@ store_page_start('Status Pesanan', 'Lacak status pesanan menggunakan order numbe
<ul class="list-clean compact-list mt-4 mb-0">
<li><span class="list-index">1</span><span>Masuk dari checkout otomatis akan mengisi kode pesanan terbaru.</span></li>
<li><span class="list-index">2</span><span>Untuk kunjungan berikutnya, pelanggan cukup ingat order number dan email.</span></li>
<li><span class="list-index">3</span><span>Status berikutnya bisa diubah admin di iterasi lanjutan.</span></li>
<li><span class="list-index">3</span><span>Status pesanan kini bisa diperbarui lewat admin lite internal.</span></li>
</ul>
</section>
<?php endif; ?>

5
robots.txt Normal file
View File

@ -0,0 +1,5 @@
User-agent: *
Allow: /
Disallow: /checkout.php
Disallow: /order_status.php
Disallow: /admin.php

View File

@ -7,6 +7,11 @@ if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
if (basename((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === basename(__FILE__)) {
header('Location: index.php');
exit;
}
const STORE_CART_KEY = 'sekut_cart';
const STORE_FLASH_KEY = 'sekut_flash';
const STORE_LAST_ORDER_KEY = 'sekut_last_order';
@ -723,6 +728,7 @@ function store_page_start(string $title, string $description = '', array $option
$metaDescription = $description !== '' ? $description : $projectDescription;
$fullTitle = trim($title) !== '' ? $title . ' • ' . $projectName : $projectName;
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string)filemtime(__DIR__ . '/assets/css/custom.css') : (string)time();
$faviconVersion = file_exists(__DIR__ . '/assets/images/favicon.svg') ? (string)filemtime(__DIR__ . '/assets/images/favicon.svg') : $cssVersion;
$currentPath = basename(parse_url($_SERVER['REQUEST_URI'] ?? '/index.php', PHP_URL_PATH) ?: 'index.php');
if ($currentPath === '' || $currentPath === '/') {
$currentPath = 'index.php';
@ -735,6 +741,7 @@ function store_page_start(string $title, string $description = '', array $option
echo '<head>';
echo '<meta charset="utf-8" />';
echo '<meta name="viewport" content="width=device-width, initial-scale=1" />';
echo '<meta name="theme-color" content="#2B2927" />';
echo '<title>' . h($fullTitle) . '</title>';
echo $robots;
@ -754,6 +761,7 @@ function store_page_start(string $title, string $description = '', array $option
echo '<link rel="preconnect" href="https://fonts.googleapis.com">';
echo '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
echo '<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">';
echo '<link rel="icon" type="image/svg+xml" href="assets/images/favicon.svg?v=' . h($faviconVersion) . '">';
echo '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">';
echo '<link rel="stylesheet" href="assets/css/custom.css?v=' . h($cssVersion) . '">';
echo '</head>';