feat: роли customer/admin, админ-панель, admin@site.com
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,11 @@ NODE_ENV=production
|
|||||||
TRUST_PROXY=1
|
TRUST_PROXY=1
|
||||||
SESSION_SECRET=change-me-to-a-long-random-string
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Первый администратор (создаётся при старте, если email не занят)
|
||||||
|
ADMIN_EMAIL=admin@site.com
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
ADMIN_NAME=Администратор
|
||||||
|
|
||||||
# PostgreSQL 17 (одна строка или отдельные переменные)
|
# PostgreSQL 17 (одна строка или отдельные переменные)
|
||||||
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
|
DATABASE_URL=postgresql://shop:shop@127.0.0.1:5432/shop
|
||||||
# PGHOST=127.0.0.1
|
# PGHOST=127.0.0.1
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'customer'
|
||||||
|
CHECK (role IN ('customer', 'admin')),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS categories (
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Роли пользователей (миграция для существующих БД)
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'customer';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'users_role_check'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('customer', 'admin'));
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const ROLES = {
|
||||||
|
CUSTOMER: 'customer',
|
||||||
|
ADMIN: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_LABELS = {
|
||||||
|
customer: 'Клиент',
|
||||||
|
admin: 'Администратор',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { ROLES, ROLE_LABELS };
|
||||||
@@ -26,10 +26,13 @@ async function query(text, params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initSchema() {
|
async function initSchema() {
|
||||||
const schemaPath = path.join(__dirname, '..', 'postgres', 'init', '01_schema.sql');
|
const initDir = path.join(__dirname, '..', 'postgres', 'init');
|
||||||
const sql = fs.readFileSync(schemaPath, 'utf8');
|
const files = fs.readdirSync(initDir).filter((f) => f.endsWith('.sql')).sort();
|
||||||
|
for (const file of files) {
|
||||||
|
const sql = fs.readFileSync(path.join(initDir, file), 'utf8');
|
||||||
await pool.query(sql);
|
await pool.query(sql);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function checkConnection() {
|
async function checkConnection() {
|
||||||
await pool.query('SELECT 1');
|
await pool.query('SELECT 1');
|
||||||
|
|||||||
+19
-2
@@ -1,5 +1,6 @@
|
|||||||
const { query } = require('../db');
|
const { query } = require('../db');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const { ROLES } = require('../constants/roles');
|
||||||
|
|
||||||
function requireAuth(req, res, next) {
|
function requireAuth(req, res, next) {
|
||||||
if (!req.session.userId) {
|
if (!req.session.userId) {
|
||||||
@@ -9,17 +10,33 @@ function requireAuth(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (!req.session.userId) {
|
||||||
|
const nextUrl = encodeURIComponent(req.originalUrl);
|
||||||
|
return res.redirect(`/login?next=${nextUrl}`);
|
||||||
|
}
|
||||||
|
if (res.locals.user?.role !== ROLES.ADMIN) {
|
||||||
|
return res.status(403).render('error', {
|
||||||
|
title: 'Доступ запрещён',
|
||||||
|
message: 'Недостаточно прав. Требуется роль администратора.',
|
||||||
|
code: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
const loadUser = asyncHandler(async (req, res, next) => {
|
const loadUser = asyncHandler(async (req, res, next) => {
|
||||||
if (req.session.userId) {
|
if (req.session.userId) {
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
'SELECT id, email, name FROM users WHERE id = $1',
|
'SELECT id, email, name, role FROM users WHERE id = $1',
|
||||||
[req.session.userId]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
res.locals.user = rows[0] || null;
|
res.locals.user = rows[0] || null;
|
||||||
} else {
|
} else {
|
||||||
res.locals.user = null;
|
res.locals.user = null;
|
||||||
}
|
}
|
||||||
|
res.locals.isAdmin = res.locals.user?.role === ROLES.ADMIN;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { requireAuth, loadUser };
|
module.exports = { requireAuth, requireAdmin, loadUser };
|
||||||
|
|||||||
@@ -579,3 +579,95 @@ a:hover {
|
|||||||
display: inline;
|
display: inline;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav__admin {
|
||||||
|
color: var(--warn);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav__link {
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-nav__link:hover,
|
||||||
|
.admin-nav__link--active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card__value {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge--customer {
|
||||||
|
background: rgba(108, 92, 231, 0.2);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge--admin {
|
||||||
|
background: rgba(253, 203, 110, 0.25);
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-status-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input--sm {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, formatPrice } = require('../db');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
const { ROLE_LABELS } = require('../constants/roles');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(requireAdmin);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const [users, products, orders, revenue] = await Promise.all([
|
||||||
|
query('SELECT COUNT(*)::int AS n FROM users'),
|
||||||
|
query('SELECT COUNT(*)::int AS n FROM products'),
|
||||||
|
query('SELECT COUNT(*)::int AS n FROM orders'),
|
||||||
|
query(
|
||||||
|
`SELECT COALESCE(SUM(total_cents), 0)::int AS total FROM orders WHERE status != 'cancelled'`
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { rows: recentOrders } = await query(
|
||||||
|
`SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, u.email AS user_email
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON u.id = o.user_id
|
||||||
|
ORDER BY o.created_at DESC
|
||||||
|
LIMIT 10`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.render('admin/dashboard', {
|
||||||
|
title: 'Админ-панель',
|
||||||
|
stats: {
|
||||||
|
users: users.rows[0].n,
|
||||||
|
products: products.rows[0].n,
|
||||||
|
orders: orders.rows[0].n,
|
||||||
|
revenue: revenue.rows[0].total,
|
||||||
|
},
|
||||||
|
recentOrders,
|
||||||
|
formatPrice,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/users',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { rows: users } = await query(
|
||||||
|
`SELECT id, email, name, role, created_at FROM users ORDER BY created_at DESC`
|
||||||
|
);
|
||||||
|
res.render('admin/users', {
|
||||||
|
title: 'Пользователи',
|
||||||
|
users,
|
||||||
|
roleLabels: ROLE_LABELS,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/orders',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { rows: orders } = await query(
|
||||||
|
`SELECT o.id, o.status, o.total_cents, o.created_at, o.customer_name, o.customer_email,
|
||||||
|
u.email AS account_email
|
||||||
|
FROM orders o
|
||||||
|
JOIN users u ON u.id = o.user_id
|
||||||
|
ORDER BY o.created_at DESC`
|
||||||
|
);
|
||||||
|
res.render('admin/orders', {
|
||||||
|
title: 'Заказы',
|
||||||
|
orders,
|
||||||
|
formatPrice,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/orders/:id/status',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { status } = req.body;
|
||||||
|
const allowed = ['pending', 'paid', 'shipped', 'cancelled'];
|
||||||
|
if (!allowed.includes(status)) {
|
||||||
|
return res.redirect('/admin/orders');
|
||||||
|
}
|
||||||
|
await query('UPDATE orders SET status = $1 WHERE id = $2', [status, req.params.id]);
|
||||||
|
res.redirect('/admin/orders');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/products',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { rows: products } = await query(
|
||||||
|
`SELECT p.*, c.name AS category_name
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN categories c ON c.id = p.category_id
|
||||||
|
ORDER BY p.id`
|
||||||
|
);
|
||||||
|
res.render('admin/products', {
|
||||||
|
title: 'Товары',
|
||||||
|
products,
|
||||||
|
formatPrice,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+8
-3
@@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs');
|
|||||||
const { query, formatPrice } = require('../db');
|
const { query, formatPrice } = require('../db');
|
||||||
const { getCart, cartCount } = require('../cart');
|
const { getCart, cartCount } = require('../cart');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const { ROLES } = require('../constants/roles');
|
||||||
const { asyncHandler } = require('../utils/asyncHandler');
|
const { asyncHandler } = require('../utils/asyncHandler');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -50,8 +51,9 @@ router.post(
|
|||||||
const hash = bcrypt.hashSync(password, 10);
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
try {
|
try {
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id',
|
`INSERT INTO users (email, password_hash, name, role)
|
||||||
[email.trim().toLowerCase(), hash, name.trim()]
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[email.trim().toLowerCase(), hash, name.trim(), ROLES.CUSTOMER]
|
||||||
);
|
);
|
||||||
req.session.userId = rows[0].id;
|
req.session.userId = rows[0].id;
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
@@ -100,6 +102,9 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
|
if (user.role === ROLES.ADMIN && (next === '/' || next === '/account')) {
|
||||||
|
return res.redirect('/admin');
|
||||||
|
}
|
||||||
res.redirect(next.startsWith('/') ? next : '/');
|
res.redirect(next.startsWith('/') ? next : '/');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -115,7 +120,7 @@ router.get(
|
|||||||
requireAuth,
|
requireAuth,
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
'SELECT id, email, name, created_at FROM users WHERE id = $1',
|
'SELECT id, email, name, role, created_at FROM users WHERE id = $1',
|
||||||
[req.session.userId]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
const user = rows[0];
|
const user = rows[0];
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { query } = require('./db');
|
||||||
|
const { ROLES } = require('./constants/roles');
|
||||||
|
|
||||||
|
async function seedAdmin() {
|
||||||
|
const email = (process.env.ADMIN_EMAIL || 'admin@site.com').trim().toLowerCase();
|
||||||
|
const password = process.env.ADMIN_PASSWORD || 'admin';
|
||||||
|
const name = process.env.ADMIN_NAME || 'Администратор';
|
||||||
|
|
||||||
|
const { rows } = await query('SELECT id, role FROM users WHERE email = $1', [email]);
|
||||||
|
|
||||||
|
if (!rows[0]) {
|
||||||
|
const hash = bcrypt.hashSync(password, 10);
|
||||||
|
await query(
|
||||||
|
`INSERT INTO users (email, password_hash, name, role)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[email, hash, name, ROLES.ADMIN]
|
||||||
|
);
|
||||||
|
console.log('Создан администратор:', email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows[0].role !== ROLES.ADMIN) {
|
||||||
|
await query('UPDATE users SET role = $1 WHERE id = $2', [ROLES.ADMIN, rows[0].id]);
|
||||||
|
console.log('Пользователю назначена роль admin:', email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { seedAdmin };
|
||||||
@@ -3,7 +3,6 @@ const { query } = require('./db');
|
|||||||
async function runSeed() {
|
async function runSeed() {
|
||||||
const { rows } = await query('SELECT COUNT(*)::int AS n FROM products');
|
const { rows } = await query('SELECT COUNT(*)::int AS n FROM products');
|
||||||
if (rows[0].n > 0) {
|
if (rows[0].n > 0) {
|
||||||
console.log('База уже содержит товары, пропуск seed.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ const pgSession = require('connect-pg-simple')(session);
|
|||||||
|
|
||||||
const { pool, initSchema, checkConnection } = require('./db');
|
const { pool, initSchema, checkConnection } = require('./db');
|
||||||
const { runSeed } = require('./seed');
|
const { runSeed } = require('./seed');
|
||||||
|
const { seedAdmin } = require('./seed-admin');
|
||||||
const { loadUser } = require('./middleware/auth');
|
const { loadUser } = require('./middleware/auth');
|
||||||
const healthRoutes = require('./routes/health');
|
const healthRoutes = require('./routes/health');
|
||||||
const shopRoutes = require('./routes/shop');
|
const shopRoutes = require('./routes/shop');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -18,6 +20,7 @@ async function start() {
|
|||||||
await checkConnection();
|
await checkConnection();
|
||||||
await initSchema();
|
await initSchema();
|
||||||
await runSeed();
|
await runSeed();
|
||||||
|
await seedAdmin();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -54,6 +57,7 @@ async function start() {
|
|||||||
app.use(loadUser);
|
app.use(loadUser);
|
||||||
app.use('/', shopRoutes);
|
app.use('/', shopRoutes);
|
||||||
app.use('/', authRoutes);
|
app.use('/', authRoutes);
|
||||||
|
app.use('/admin', adminRoutes);
|
||||||
|
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).render('error', {
|
res.status(404).render('error', {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<div class="card account-card">
|
<div class="card account-card">
|
||||||
<p><strong><%= user.name %></strong></p>
|
<p><strong><%= user.name %></strong></p>
|
||||||
<p class="muted"><%= user.email %></p>
|
<p class="muted"><%= user.email %></p>
|
||||||
|
<% const roleLabels = { customer: 'Клиент', admin: 'Администратор' }; %>
|
||||||
|
<p><span class="role-badge role-badge--<%= user.role %>"><%= roleLabels[user.role] || user.role %></span></p>
|
||||||
<p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
|
<p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
|
||||||
<div class="account-actions">
|
<div class="account-actions">
|
||||||
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
|
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<%- include('../partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Админ-панель</h1>
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/admin" class="admin-nav__link admin-nav__link--active">Обзор</a>
|
||||||
|
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||||
|
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||||
|
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||||
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__label">Пользователи</span>
|
||||||
|
<strong class="stat-card__value"><%= stats.users %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__label">Товары</span>
|
||||||
|
<strong class="stat-card__value"><%= stats.products %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__label">Заказы</span>
|
||||||
|
<strong class="stat-card__value"><%= stats.orders %></strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-card__label">Выручка</span>
|
||||||
|
<strong class="stat-card__value"><%= formatPrice(stats.revenue) %></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Последние заказы</h2>
|
||||||
|
<% if (!recentOrders.length) { %>
|
||||||
|
<p class="muted">Заказов пока нет.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Клиент</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||||
|
<% recentOrders.forEach(o => { %>
|
||||||
|
<tr>
|
||||||
|
<td>#<%= o.id %></td>
|
||||||
|
<td><%= o.customer_name %><br><span class="muted"><%= o.user_email %></span></td>
|
||||||
|
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
|
||||||
|
<td><%= formatPrice(o.total_cents) %></td>
|
||||||
|
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%- include('../partials/layout-end') %>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<%- include('../partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Заказы</h1>
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||||
|
<a href="/admin/orders" class="admin-nav__link admin-nav__link--active">Заказы</a>
|
||||||
|
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||||
|
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||||
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||||
|
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Клиент</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Действие</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% orders.forEach(o => { %>
|
||||||
|
<tr>
|
||||||
|
<td>#<%= o.id %></td>
|
||||||
|
<td>
|
||||||
|
<%= o.customer_name %><br>
|
||||||
|
<span class="muted"><%= o.customer_email %></span>
|
||||||
|
</td>
|
||||||
|
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
|
||||||
|
<td><%= formatPrice(o.total_cents) %></td>
|
||||||
|
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/orders/<%= o.id %>/status" class="inline-form admin-status-form">
|
||||||
|
<select name="status" class="input input--sm">
|
||||||
|
<% ['pending','paid','shipped','cancelled'].forEach(s => { %>
|
||||||
|
<option value="<%= s %>" <%= o.status === s ? 'selected' : '' %>><%= statusLabels[s] %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">OK</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<%- include('../partials/layout-end') %>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<%- include('../partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Товары</h1>
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||||
|
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||||
|
<a href="/admin/users" class="admin-nav__link">Пользователи</a>
|
||||||
|
<a href="/admin/products" class="admin-nav__link admin-nav__link--active">Товары</a>
|
||||||
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Остаток</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% products.forEach(p => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= p.id %></td>
|
||||||
|
<td><%= p.name %></td>
|
||||||
|
<td><%= p.category_name || '—' %></td>
|
||||||
|
<td><%= formatPrice(p.price_cents) %></td>
|
||||||
|
<td><%= p.stock %></td>
|
||||||
|
<td><a href="/product/<%= p.slug %>">На сайте</a></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<%- include('../partials/layout-end') %>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<%- include('../partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Пользователи</h1>
|
||||||
|
<nav class="admin-nav">
|
||||||
|
<a href="/admin" class="admin-nav__link">Обзор</a>
|
||||||
|
<a href="/admin/orders" class="admin-nav__link">Заказы</a>
|
||||||
|
<a href="/admin/users" class="admin-nav__link admin-nav__link--active">Пользователи</a>
|
||||||
|
<a href="/admin/products" class="admin-nav__link">Товары</a>
|
||||||
|
<a href="/" class="admin-nav__link">В магазин</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Регистрация</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% users.forEach(u => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= u.id %></td>
|
||||||
|
<td><%= u.name %></td>
|
||||||
|
<td><%= u.email %></td>
|
||||||
|
<td>
|
||||||
|
<span class="role-badge role-badge--<%= u.role %>"><%= roleLabels[u.role] || u.role %></span>
|
||||||
|
</td>
|
||||||
|
<td><%= new Date(u.created_at).toLocaleString('ru-RU') %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<%- include('../partials/layout-end') %>
|
||||||
@@ -23,6 +23,9 @@
|
|||||||
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
|
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
|
||||||
</a>
|
</a>
|
||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
|
<% if (typeof isAdmin !== 'undefined' && isAdmin) { %>
|
||||||
|
<a href="/admin" class="nav__link nav__admin">Админ</a>
|
||||||
|
<% } %>
|
||||||
<a href="/account" class="nav__link"><%= user.name %></a>
|
<a href="/account" class="nav__link"><%= user.name %></a>
|
||||||
<form action="/logout" method="post" class="inline-form">
|
<form action="/logout" method="post" class="inline-form">
|
||||||
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user