Compare commits
2 Commits
83a59f1677
...
82bb456103
| Author | SHA1 | Date | |
|---|---|---|---|
| 82bb456103 | |||
| d5a80bb104 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
dist/
|
||||
data/
|
||||
backups/
|
||||
*.log
|
||||
|
||||
26
Makefile
26
Makefile
@ -1,4 +1,8 @@
|
||||
.PHONY: help install run build db-init db-reset docker-build docker-shell clean clean-all bundle bundle-clean
|
||||
.PHONY: help install run build db-init db-reset docker-build docker-shell clean clean-all bundle patch bundle-clean
|
||||
|
||||
SHELL := /bin/bash
|
||||
# Activate the Node version pinned in .nvmrc (16.x) for every recipe.
|
||||
NVM := . $$HOME/.nvm/nvm.sh && nvm use --silent
|
||||
|
||||
DC := docker compose
|
||||
|
||||
@ -8,40 +12,41 @@ help:
|
||||
@echo " ║ AvtoAmbor — auto parts inventory (dev tasks) ║"
|
||||
@echo " ╚════════════════════════════════════════════════╝"
|
||||
@echo ""
|
||||
@echo " make install Install npm dependencies inside the container"
|
||||
@echo " make install Install npm dependencies (Node 16 via nvm)"
|
||||
@echo " make run Start the dev server (http://localhost:3000)"
|
||||
@echo " make build Production build into ./build (adapter-node)"
|
||||
@echo " make db-init Create data/avtoambor.db from schema + seed (skip if exists)"
|
||||
@echo " make db-reset DELETE and recreate data/avtoambor.db (asks first)"
|
||||
@echo " make docker-build Rebuild the Docker image"
|
||||
@echo " make docker-build Rebuild the Docker image (legacy; dev runs on host now)"
|
||||
@echo " make docker-shell Open an interactive bash shell in the container"
|
||||
@echo " make clean Remove node_modules and build/ (keeps data/)"
|
||||
@echo " make clean-all Also wipe data/ (destroys the DB)"
|
||||
@echo " make bundle Produce dist/avtoambor-deploy.zip for Windows 7"
|
||||
@echo " make patch Produce dist/avtoambor-patch.zip (build/ only) for an installed target"
|
||||
@echo " make bundle-clean Remove dist/"
|
||||
@echo ""
|
||||
|
||||
install:
|
||||
@$(DC) run --rm app npm install
|
||||
@$(NVM) && npm install
|
||||
|
||||
run:
|
||||
@$(DC) up
|
||||
@$(NVM) && npm run dev
|
||||
|
||||
build:
|
||||
@$(DC) run --rm app npm run build
|
||||
@$(NVM) && npm run build
|
||||
|
||||
db-init:
|
||||
@if [ -f data/avtoambor.db ]; then \
|
||||
echo "data/avtoambor.db already exists — skipping. Use 'make db-reset' to recreate."; \
|
||||
else \
|
||||
mkdir -p data && $(DC) run --rm app node scripts/init-db.js; \
|
||||
mkdir -p data && $(NVM) && node scripts/init-db.js; \
|
||||
fi
|
||||
|
||||
db-reset:
|
||||
@printf "This will DELETE data/avtoambor.db. Continue? [y/N] " && read ans && [ "$$ans" = "y" ] || (echo "aborted." && exit 1)
|
||||
@rm -f data/avtoambor.db data/avtoambor.db-shm data/avtoambor.db-wal
|
||||
@mkdir -p data
|
||||
@$(DC) run --rm app node scripts/init-db.js
|
||||
@$(NVM) && node scripts/init-db.js
|
||||
|
||||
docker-build:
|
||||
@$(DC) build
|
||||
@ -61,6 +66,11 @@ clean-all: clean
|
||||
bundle:
|
||||
@bash scripts/make-bundle.sh
|
||||
|
||||
# Small build/-only update zip for an already-installed target.
|
||||
# Requires a prior `make bundle` to establish dist/avtoambor/ as the baseline.
|
||||
patch:
|
||||
@bash scripts/make-patch.sh
|
||||
|
||||
bundle-clean:
|
||||
@rm -rf dist
|
||||
@echo "removed dist/"
|
||||
|
||||
@ -34,7 +34,8 @@ set "PORT=3000"
|
||||
set "HOST=0.0.0.0"
|
||||
set "ORIGIN=http://localhost:3000"
|
||||
|
||||
start "" http://localhost:3000
|
||||
REM start "" http://localhost:3000
|
||||
"C:\Program Files\Google\Chrome\Application\chrome_proxy.exe" --profile-directory=Default --app-id=jndfkokbljfmkpnammckejpeijmbbhhe
|
||||
|
||||
echo Сервер запущен на http://localhost:3000
|
||||
echo Закройте это окно, чтобы остановить программу.
|
||||
|
||||
106
scripts/make-patch.sh
Executable file
106
scripts/make-patch.sh
Executable file
@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build dist/avtoambor-patch.zip — a small build/-only update for an
|
||||
# already-installed Windows deployment.
|
||||
#
|
||||
# Use this when only application code has changed since the last full bundle.
|
||||
# The patch is just the SvelteKit build output; node_modules, .bat launchers,
|
||||
# and the native better-sqlite3 binary on the target stay untouched.
|
||||
#
|
||||
# The script compares package-lock.json and src/lib/server/*.sql against the
|
||||
# staging snapshot left by scripts/make-bundle.sh (dist/avtoambor/). If those
|
||||
# changed, a full re-bundle is required instead — the patch alone won't work.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$(pwd)"
|
||||
DIST="$ROOT/dist"
|
||||
BASELINE="$DIST/avtoambor"
|
||||
PATCH_DIR="$DIST/patch"
|
||||
ZIP="$DIST/avtoambor-patch.zip"
|
||||
|
||||
for cmd in npm zip rsync diff; do
|
||||
command -v "$cmd" >/dev/null || { echo "make-patch.sh: missing required tool: $cmd"; exit 1; }
|
||||
done
|
||||
|
||||
if [ ! -d "$BASELINE" ]; then
|
||||
echo "make-patch.sh: no baseline at $BASELINE."
|
||||
echo " Run scripts/make-bundle.sh first to establish a baseline."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- safety checks: things a build/-only patch cannot deliver ---
|
||||
WARN=0
|
||||
warn() { echo " ! $*"; WARN=1; }
|
||||
|
||||
echo "==> Checking patch safety against baseline ($BASELINE)"
|
||||
if ! diff -q "$ROOT/package-lock.json" "$BASELINE/package-lock.json" >/dev/null 2>&1; then
|
||||
warn "package-lock.json changed — node_modules on target is stale. Full bundle required."
|
||||
fi
|
||||
for f in schema.sql seed.sql; do
|
||||
if ! diff -q "$ROOT/src/lib/server/$f" "$BASELINE/src/lib/server/$f" >/dev/null 2>&1; then
|
||||
warn "src/lib/server/$f changed — target DB may need a migration."
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$WARN" = 1 ]; then
|
||||
echo
|
||||
echo "Patch may be unsafe."
|
||||
echo "Either:"
|
||||
echo " - run scripts/make-bundle.sh and ship the full bundle, or"
|
||||
echo " - re-run this script with FORCE=1 if you know the change is harmless."
|
||||
if [ "${FORCE:-0}" != "1" ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "FORCE=1 set — continuing anyway."
|
||||
fi
|
||||
|
||||
echo "==> Building production output (vite build)"
|
||||
npm run build
|
||||
|
||||
echo "==> Staging patch contents"
|
||||
rm -rf "$PATCH_DIR"
|
||||
mkdir -p "$PATCH_DIR"
|
||||
rsync -a --delete build/ "$PATCH_DIR/build/"
|
||||
|
||||
STAMP="$(date +%Y-%m-%d)"
|
||||
cat > "$PATCH_DIR/UPDATE.txt" <<EOF
|
||||
Замена Масла ГП — обновление программы ($STAMP)
|
||||
================================================
|
||||
|
||||
Этот архив содержит только новую версию программы (папка build).
|
||||
Ваши данные (data\\, backups\\) и установленный Node.js не затрагиваются.
|
||||
|
||||
Как установить обновление:
|
||||
|
||||
1. Закройте чёрное окно "start.bat", если программа запущена.
|
||||
|
||||
2. Распакуйте этот архив в папку C:\\avtoambor\\
|
||||
(туда же, где лежат install.bat и start.bat).
|
||||
Windows спросит, заменить ли существующие файлы — ответьте "Да"
|
||||
(или "Заменить файлы в папке назначения").
|
||||
|
||||
3. Снова запустите start.bat двойным щелчком.
|
||||
|
||||
Если после обновления программа не запускается — напишите Давиду.
|
||||
EOF
|
||||
|
||||
echo "==> Creating zip: $ZIP"
|
||||
rm -f "$ZIP"
|
||||
( cd "$PATCH_DIR" && zip -rq "$ZIP" build UPDATE.txt )
|
||||
|
||||
ZIP_SIZE="$(du -h "$ZIP" | cut -f1)"
|
||||
echo
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo " Patch ready: $ZIP ($ZIP_SIZE)"
|
||||
echo "═══════════════════════════════════════════════════════════════"
|
||||
echo
|
||||
echo " Next steps (you):"
|
||||
echo " 1. Upload $ZIP to your server."
|
||||
echo " 2. Send him the download link."
|
||||
echo
|
||||
echo " What he does (also written in UPDATE.txt inside the zip):"
|
||||
echo " 1. Close the start.bat window."
|
||||
echo " 2. Extract the zip into C:\\avtoambor\\ — choose Replace when asked."
|
||||
echo " 3. Double-click start.bat."
|
||||
echo
|
||||
@ -151,6 +151,9 @@
|
||||
"top_parts": "Top selling parts",
|
||||
"units_sold": "Units sold",
|
||||
"revenue": "Revenue",
|
||||
"sale": "Sale",
|
||||
"cog": "COG",
|
||||
"profit": "Profit",
|
||||
"recent_sales": "Recent sales",
|
||||
"saved_at": "Saved",
|
||||
"lines": "Lines",
|
||||
|
||||
@ -151,6 +151,9 @@
|
||||
"top_parts": "Қисмҳои серфурӯш",
|
||||
"units_sold": "Фурӯхта шуд",
|
||||
"revenue": "Даромад",
|
||||
"sale": "Фурӯш",
|
||||
"cog": "Арзиши мол",
|
||||
"profit": "Фоида",
|
||||
"recent_sales": "Фурӯшҳои охирин",
|
||||
"saved_at": "Сабт шуд",
|
||||
"lines": "Сатрҳо",
|
||||
|
||||
@ -1,45 +1,38 @@
|
||||
import { getDb } from './db.js';
|
||||
|
||||
// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`.
|
||||
// Cost of goods (COG) and profit are computed against each part's current cost_price —
|
||||
// the schema does not snapshot cost at sale time, so historical cost changes are not
|
||||
// reflected. Custom (non-inventory) lines contribute to sale revenue but have zero COG.
|
||||
|
||||
const COG_SUBQUERY = `
|
||||
COALESCE((
|
||||
SELECT SUM(l.quantity * p.cost_price)
|
||||
FROM invoice_lines l
|
||||
JOIN parts p ON p.id = l.part_id
|
||||
WHERE l.invoice_id = invoices.id AND l.affects_inventory = 1
|
||||
), 0)
|
||||
`;
|
||||
|
||||
function windowStats(dateClause) {
|
||||
return getDb().prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS invoice_count,
|
||||
COALESCE(SUM(total_dirams), 0) AS sale_dirams,
|
||||
COALESCE(SUM(${COG_SUBQUERY}), 0) AS cog_dirams,
|
||||
COALESCE(SUM(total_dirams - ${COG_SUBQUERY}), 0) AS profit_dirams
|
||||
FROM invoices
|
||||
WHERE status = 'saved'${dateClause ? ' AND ' + dateClause : ''}
|
||||
`).get();
|
||||
}
|
||||
|
||||
export function salesSummary() {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS invoice_count,
|
||||
COALESCE(SUM(total_dirams), 0) AS total_dirams
|
||||
FROM invoices
|
||||
WHERE status = 'saved'
|
||||
`).get();
|
||||
|
||||
const today = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS invoice_count,
|
||||
COALESCE(SUM(total_dirams), 0) AS total_dirams
|
||||
FROM invoices
|
||||
WHERE status = 'saved'
|
||||
AND date(saved_at, 'localtime') = date('now', 'localtime')
|
||||
`).get();
|
||||
|
||||
const week = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS invoice_count,
|
||||
COALESCE(SUM(total_dirams), 0) AS total_dirams
|
||||
FROM invoices
|
||||
WHERE status = 'saved'
|
||||
AND date(saved_at, 'localtime') >= date('now', 'localtime', '-6 days')
|
||||
`).get();
|
||||
|
||||
const month = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) AS invoice_count,
|
||||
COALESCE(SUM(total_dirams), 0) AS total_dirams
|
||||
FROM invoices
|
||||
WHERE status = 'saved'
|
||||
AND strftime('%Y-%m', saved_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')
|
||||
`).get();
|
||||
|
||||
return { all_time: row, today, week, month };
|
||||
return {
|
||||
all_time: windowStats(''),
|
||||
today: windowStats(`date(saved_at, 'localtime') = date('now', 'localtime')`),
|
||||
week: windowStats(`date(saved_at, 'localtime') >= date('now', 'localtime', '-6 days')`),
|
||||
month: windowStats(`strftime('%Y-%m', saved_at, 'localtime') = strftime('%Y-%m', 'now', 'localtime')`)
|
||||
};
|
||||
}
|
||||
|
||||
export function topSellingParts(limit = 10) {
|
||||
@ -47,13 +40,15 @@ export function topSellingParts(limit = 10) {
|
||||
SELECT
|
||||
p.id, p.sku, p.name_en, p.name_tg,
|
||||
SUM(l.quantity) AS units_sold,
|
||||
SUM(l.quantity * l.unit_price_dirams) AS revenue_dirams
|
||||
SUM(l.quantity * l.unit_price_dirams) AS sale_dirams,
|
||||
SUM(l.quantity * p.cost_price) AS cog_dirams,
|
||||
SUM(l.quantity * (l.unit_price_dirams - p.cost_price)) AS profit_dirams
|
||||
FROM invoice_lines l
|
||||
JOIN invoices i ON i.id = l.invoice_id
|
||||
JOIN parts p ON p.id = l.part_id
|
||||
WHERE i.status = 'saved' AND l.affects_inventory = 1
|
||||
GROUP BY p.id
|
||||
ORDER BY units_sold DESC, revenue_dirams DESC
|
||||
ORDER BY profit_dirams DESC, units_sold DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
@ -84,7 +79,12 @@ export function inventorySummary() {
|
||||
|
||||
export function recentSales(limit = 10) {
|
||||
return getDb().prepare(`
|
||||
SELECT id, total_dirams, saved_at,
|
||||
SELECT
|
||||
id,
|
||||
total_dirams AS sale_dirams,
|
||||
${COG_SUBQUERY} AS cog_dirams,
|
||||
total_dirams - ${COG_SUBQUERY} AS profit_dirams,
|
||||
saved_at,
|
||||
(SELECT COUNT(*) FROM invoice_lines WHERE invoice_id = invoices.id) AS line_count
|
||||
FROM invoices
|
||||
WHERE status = 'saved'
|
||||
|
||||
@ -16,38 +16,26 @@
|
||||
<h2>{$t('reports.sales_heading')}</h2>
|
||||
|
||||
<div class="grid">
|
||||
{#each [
|
||||
{ label: $t('reports.today'), row: sales.today },
|
||||
{ label: $t('reports.last_7_days'), row: sales.week },
|
||||
{ label: $t('reports.this_month'), row: sales.month },
|
||||
{ label: $t('reports.all_time'), row: sales.all_time }
|
||||
] as card}
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('reports.today')}</div>
|
||||
<div class="value">
|
||||
{formatMoney(sales.today.total_dirams, lang)}
|
||||
<div class="label">{card.label}</div>
|
||||
<div class="profit-label">{$t('reports.profit')}</div>
|
||||
<div class="value profit" class:negative={card.row.profit_dirams < 0}>
|
||||
{formatMoney(card.row.profit_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</div>
|
||||
<div class="sub">{sales.today.invoice_count} {$t('reports.invoices')}</div>
|
||||
<div class="breakdown">
|
||||
<div><span class="bk-label">{$t('reports.sale')}</span> {formatMoney(card.row.sale_dirams, lang)}</div>
|
||||
<div><span class="bk-label">{$t('reports.cog')}</span> {formatMoney(card.row.cog_dirams, lang)}</div>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('reports.last_7_days')}</div>
|
||||
<div class="value">
|
||||
{formatMoney(sales.week.total_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</div>
|
||||
<div class="sub">{sales.week.invoice_count} {$t('reports.invoices')}</div>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('reports.this_month')}</div>
|
||||
<div class="value">
|
||||
{formatMoney(sales.month.total_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</div>
|
||||
<div class="sub">{sales.month.invoice_count} {$t('reports.invoices')}</div>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('reports.all_time')}</div>
|
||||
<div class="value">
|
||||
{formatMoney(sales.all_time.total_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</div>
|
||||
<div class="sub">{sales.all_time.invoice_count} {$t('reports.invoices')}</div>
|
||||
<div class="sub">{card.row.invoice_count} {$t('reports.invoices')}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<h2>{$t('reports.inventory_heading')}</h2>
|
||||
@ -95,7 +83,9 @@
|
||||
<th>{$t('parts.sku')}</th>
|
||||
<th>{$t('parts.name')}</th>
|
||||
<th class="num">{$t('reports.units_sold')}</th>
|
||||
<th class="num">{$t('reports.revenue')}</th>
|
||||
<th class="num">{$t('reports.sale')}</th>
|
||||
<th class="num">{$t('reports.cog')}</th>
|
||||
<th class="num profit-col">{$t('reports.profit')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -105,7 +95,15 @@
|
||||
<td>{localized(p, 'name', lang)}</td>
|
||||
<td class="num">{p.units_sold}</td>
|
||||
<td class="num">
|
||||
{formatMoney(p.revenue_dirams, lang)}
|
||||
{formatMoney(p.sale_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
{formatMoney(p.cog_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
<td class="num profit-col" class:negative={p.profit_dirams < 0}>
|
||||
{formatMoney(p.profit_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
@ -123,7 +121,9 @@
|
||||
<tr>
|
||||
<th>{$t('reports.saved_at')}</th>
|
||||
<th class="num">{$t('reports.lines')}</th>
|
||||
<th class="num">{$t('common.total')}</th>
|
||||
<th class="num">{$t('reports.sale')}</th>
|
||||
<th class="num">{$t('reports.cog')}</th>
|
||||
<th class="num profit-col">{$t('reports.profit')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -133,7 +133,15 @@
|
||||
<td>{formatWhen(s.saved_at)}</td>
|
||||
<td class="num">{s.line_count}</td>
|
||||
<td class="num">
|
||||
{formatMoney(s.total_dirams, lang)}
|
||||
{formatMoney(s.sale_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
<td class="num">
|
||||
{formatMoney(s.cog_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
<td class="num profit-col" class:negative={s.profit_dirams < 0}>
|
||||
{formatMoney(s.profit_dirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</td>
|
||||
<td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td>
|
||||
@ -146,7 +154,7 @@
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@ -159,5 +167,49 @@
|
||||
}
|
||||
.stat .value.warn { color: #b8443f; }
|
||||
.stat .cur { font-size: 0.8rem; color: #6b7388; margin-left: 0.2rem; }
|
||||
.stat .sub { color: #6b7388; font-size: 0.8rem; margin-top: 0.2rem; }
|
||||
.stat .sub { color: #6b7388; font-size: 0.8rem; margin-top: 0.4rem; }
|
||||
|
||||
.profit-label {
|
||||
color: #2f7d4f;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.value.profit {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f6b40;
|
||||
line-height: 1.1;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
.value.profit.negative { color: #b8443f; }
|
||||
.value.profit .cur { color: #1f6b40; }
|
||||
.value.profit.negative .cur { color: #b8443f; }
|
||||
|
||||
.breakdown {
|
||||
margin-top: 0.55rem;
|
||||
color: #4a5060;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.1rem 0.9rem;
|
||||
}
|
||||
.breakdown .bk-label {
|
||||
color: #6b7388;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
|
||||
th.profit-col, td.profit-col {
|
||||
color: #1f6b40;
|
||||
font-weight: 700;
|
||||
background: #f1f8f3;
|
||||
}
|
||||
td.profit-col.negative { color: #b8443f; background: #fbf1f0; }
|
||||
td.profit-col .cur { color: inherit; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user