Compare commits

..

13 Commits

Author SHA1 Message Date
0314e87c3f remove patch file 2026-06-17 20:46:15 +05:00
32331d4cf8 Merge branch 'master' of ssh://gitea.rareobj.com:222/dab/avtoambor 2026-06-17 20:41:13 +05:00
a85979731f use tajik timezone for displaying dates 2026-06-17 20:40:43 +05:00
45ef55e13e fix favicon 2026-06-17 18:43:05 +05:00
0000748fe0 add favicon 2026-06-17 13:40:19 +00:00
a287d26b93 fix gitignore and install bat 2026-06-17 18:06:29 +05:00
d4cba18017 Merge branch 'master' of https://gitea.rareobj.com/dab/avtoambor 2026-06-17 18:05:04 +05:00
259f8d4b8f update gitignore 2026-05-23 22:46:09 +05:00
66e15dee1f change default unit to liter
Since the inventory is mostly containing enginge oils, which are sold per liter, the default unit for a new part is set to liter
2026-05-23 16:43:39 +05:00
aac71becfc remove the invetory sale value from report
Since product prices can be negotiated with every client, the inventory value at sale cannot be determined. Removing the number from the report but keeping the placeholder.
2026-05-23 16:16:49 +05:00
8cbaa55b48 Soft-delete parts and hide SKU/location/description from the UI
Deleting a part used to be impossible. Hard delete would cascade
stock_movements (FK ON DELETE CASCADE) and orphan invoice_lines, losing
the audit trail. Instead, the part detail page now has a Delete button
that flips active=0; listParts and categoriesWithParts filter on active,
but historical joins (recentMovements, linesFor, topSellingParts) stay
unfiltered so old movements and invoices still render the part name.
The existing active checkbox on the detail page doubles as a reactivate
switch.

SKU, location, and description fields are removed from every UI surface
(forms, /parts table, dashboard, movement/invoice pickers, invoice line
labels, top-sellers report). None were load-bearing — barcode + name +
category already cover lookup. The SKU column is kept in the DB
(NOT NULL UNIQUE) and auto-stamped server-side as `SKU-{id}` after
insert, so the change is reversible without a migration. updatePart no
longer writes SKU, freezing it after creation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:49:53 +05:00
82bb456103 Run dev on host Node 16 and add deploy patch script
Switch make install/run/build/db-* off docker compose onto the host's
Node (via nvm + .nvmrc). better-sqlite3 7.6.2 doesn't build against
Node 20, and the Dockerfile was still pinned to node:20 while the
rest of the project was downgraded to Node 16 in the Win7 bundle
commit; host execution avoids that mismatch.

Also adds scripts/make-patch.sh (make patch) to produce a small
build-only update zip for an already-installed Win7 target, ignores
dist/, and points start.bat at the installed Chrome PWA shortcut
instead of opening a normal browser tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:34:41 +05:00
d5a80bb104 Show sale, COG, and profit on /admin/reports
Each summary card now leads with profit (large, green, bold), with
sale and COG shown underneath as a breakdown. The "Top selling parts"
and "Recent sales" tables get Sale/COG/Profit columns, with profit
emphasized; negative profit flips both styles to red. Top parts is
now ordered by profit. COG is computed from each part's current
cost_price for inventory-affecting lines only — custom lines
contribute to sale with zero COG.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 20:34:30 +05:00
22 changed files with 376 additions and 224 deletions

3
.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules/ node_modules/
.svelte-kit/ .svelte-kit/
build/ build/
dist/
data/ data/
backups/ backups/
*.log *.log
@ -11,3 +12,5 @@ backups/
*.sw? *.sw?
.session.vim .session.vim
.claude/settings.local.json .claude/settings.local.json
*~
/*.bat

View File

@ -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 DC := docker compose
@ -8,40 +12,41 @@ help:
@echo " ║ AvtoAmbor — auto parts inventory (dev tasks) ║" @echo " ║ AvtoAmbor — auto parts inventory (dev tasks) ║"
@echo " ╚════════════════════════════════════════════════╝" @echo " ╚════════════════════════════════════════════════╝"
@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 run Start the dev server (http://localhost:3000)"
@echo " make build Production build into ./build (adapter-node)" @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-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 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 docker-shell Open an interactive bash shell in the container"
@echo " make clean Remove node_modules and build/ (keeps data/)" @echo " make clean Remove node_modules and build/ (keeps data/)"
@echo " make clean-all Also wipe data/ (destroys the DB)" @echo " make clean-all Also wipe data/ (destroys the DB)"
@echo " make bundle Produce dist/avtoambor-deploy.zip for Windows 7" @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 " make bundle-clean Remove dist/"
@echo "" @echo ""
install: install:
@$(DC) run --rm app npm install @$(NVM) && npm install
run: run:
@$(DC) up @$(NVM) && npm run dev
build: build:
@$(DC) run --rm app npm run build @$(NVM) && npm run build
db-init: db-init:
@if [ -f data/avtoambor.db ]; then \ @if [ -f data/avtoambor.db ]; then \
echo "data/avtoambor.db already exists — skipping. Use 'make db-reset' to recreate."; \ echo "data/avtoambor.db already exists — skipping. Use 'make db-reset' to recreate."; \
else \ else \
mkdir -p data && $(DC) run --rm app node scripts/init-db.js; \ mkdir -p data && $(NVM) && node scripts/init-db.js; \
fi fi
db-reset: db-reset:
@printf "This will DELETE data/avtoambor.db. Continue? [y/N] " && read ans && [ "$$ans" = "y" ] || (echo "aborted." && exit 1) @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 @rm -f data/avtoambor.db data/avtoambor.db-shm data/avtoambor.db-wal
@mkdir -p data @mkdir -p data
@$(DC) run --rm app node scripts/init-db.js @$(NVM) && node scripts/init-db.js
docker-build: docker-build:
@$(DC) build @$(DC) build
@ -61,6 +66,11 @@ clean-all: clean
bundle: bundle:
@bash scripts/make-bundle.sh @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: bundle-clean:
@rm -rf dist @rm -rf dist
@echo "removed dist/" @echo "removed dist/"

View File

@ -6,16 +6,6 @@ chcp 65001 >nul
setlocal setlocal
cd /d "%~dp0" cd /d "%~dp0"
if exist "%ProgramFiles%\nodejs\node.exe" (
echo Node.js уже установлен в %ProgramFiles%\nodejs.
echo Пропускаем установку.
goto :done
)
if exist "%ProgramFiles(x86)%\nodejs\node.exe" (
echo Node.js уже установлен в %ProgramFiles(x86)%\nodejs.
echo Пропускаем установку.
goto :done
)
set "MSI=node-v16.20.2-x64.msi" set "MSI=node-v16.20.2-x64.msi"
if /i "%PROCESSOR_ARCHITECTURE%"=="x86" if not defined PROCESSOR_ARCHITEW6432 set "MSI=node-v16.20.2-x86.msi" if /i "%PROCESSOR_ARCHITECTURE%"=="x86" if not defined PROCESSOR_ARCHITEW6432 set "MSI=node-v16.20.2-x86.msi"

View File

@ -34,7 +34,8 @@ set "PORT=3000"
set "HOST=0.0.0.0" set "HOST=0.0.0.0"
set "ORIGIN=http://localhost:3000" 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 Сервер запущен на http://localhost:3000
echo Закройте это окно, чтобы остановить программу. echo Закройте это окно, чтобы остановить программу.

106
scripts/make-patch.sh Executable file
View 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

View File

@ -42,7 +42,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Dashboard", "title": "Dashboard",
"total_skus": "Total SKUs", "total_skus": "Total parts",
"low_stock": "At or below reorder level", "low_stock": "At or below reorder level",
"inventory_value": "Inventory value (at cost)", "inventory_value": "Inventory value (at cost)",
"low_stock_list": "Low stock", "low_stock_list": "Low stock",
@ -68,11 +68,12 @@
"location": "Location", "location": "Location",
"barcode": "Barcode", "barcode": "Barcode",
"active": "Active", "active": "Active",
"search_placeholder": "Search by SKU, name, or barcode…", "search_placeholder": "Search by name or barcode…",
"no_results": "No parts match your search.", "no_results": "No parts match your search.",
"all": "All", "all": "All",
"recent_movements": "Recent movements", "recent_movements": "Recent movements",
"initial_quantity": "Initial quantity", "initial_quantity": "Initial quantity",
"delete_confirm": "Deactivate part \"{name}\"? It will be hidden from lists, but movements and counts will be kept.",
"errors": { "errors": {
"sku_required": "SKU is required.", "sku_required": "SKU is required.",
"name_required": "At least one name (English or Tajik) is required.", "name_required": "At least one name (English or Tajik) is required.",
@ -142,15 +143,17 @@
"this_month": "This month", "this_month": "This month",
"all_time": "All time", "all_time": "All time",
"invoices": "invoices", "invoices": "invoices",
"active_skus": "Active SKUs", "active_skus": "Active parts",
"units_on_hand": "Units on hand", "units_on_hand": "Units on hand",
"cost_value": "Value (at cost)", "cost_value": "Value (at cost)",
"sale_value": "Value (at sale)",
"low_stock": "Low stock", "low_stock": "Low stock",
"out_of_stock": "Out of stock", "out_of_stock": "Out of stock",
"top_parts": "Top selling parts", "top_parts": "Top selling parts",
"units_sold": "Units sold", "units_sold": "Units sold",
"revenue": "Revenue", "revenue": "Revenue",
"sale": "Sale",
"cog": "COG",
"profit": "Profit",
"recent_sales": "Recent sales", "recent_sales": "Recent sales",
"saved_at": "Saved", "saved_at": "Saved",
"lines": "Lines", "lines": "Lines",

View File

@ -85,3 +85,20 @@ export function formatMoney(dirams, lang = 'en') {
const s = n.toFixed(2); const s = n.toFixed(2);
return lang === 'tg' ? s.replace('.', ',') : s; return lang === 'tg' ? s.replace('.', ',') : s;
} }
export function formatTs(utcStr) {
if (!utcStr) return '';
const normalized = String(utcStr).trim().replace(' ', 'T');
const utcDate = new Date(`${normalized}Z`);
if (Number.isNaN(utcDate.getTime())) return utcStr;
const tajikDate = new Date(utcDate.getTime() + 5 * 60 * 60 * 1000);
const pad = (n) => String(n).padStart(2, '0');
return [
pad(tajikDate.getUTCDate()),
pad(tajikDate.getUTCMonth() + 1),
tajikDate.getUTCFullYear()
].join('.') + ` ${pad(tajikDate.getUTCHours())}:${pad(tajikDate.getUTCMinutes())}`;
}

View File

@ -42,7 +42,7 @@
}, },
"dashboard": { "dashboard": {
"title": "Лавҳаи асосӣ", "title": "Лавҳаи асосӣ",
"total_skus": "Ҳамаи SKU-ҳо", "total_skus": "Ҳамаи қисмҳо",
"low_stock": "Дар сатҳи фармоиш ё камтар", "low_stock": "Дар сатҳи фармоиш ё камтар",
"inventory_value": "Арзиши захира (бо нархи харид)", "inventory_value": "Арзиши захира (бо нархи харид)",
"low_stock_list": "Захираи кам", "low_stock_list": "Захираи кам",
@ -68,11 +68,12 @@
"location": "Ҷой", "location": "Ҷой",
"barcode": "Штрих-код", "barcode": "Штрих-код",
"active": "Фаъол", "active": "Фаъол",
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…", "search_placeholder": "Ҷустуҷӯ аз рӯи ном ё штрих-код…",
"no_results": "Ҳеҷ қисм мувофиқат намекунад.", "no_results": "Ҳеҷ қисм мувофиқат намекунад.",
"all": "Ҳама", "all": "Ҳама",
"recent_movements": "Ҳаракатҳои охирин", "recent_movements": "Ҳаракатҳои охирин",
"initial_quantity": "Шумораи аввала", "initial_quantity": "Шумораи аввала",
"delete_confirm": "Қисми «{name}»-ро ғайрифаъол мекунед? Дар рӯйхатҳо нишон дода намешавад, аммо ҳаракатҳо ва ҳисобҳо боқӣ мемонанд.",
"errors": { "errors": {
"sku_required": "SKU зарур аст.", "sku_required": "SKU зарур аст.",
"name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.", "name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.",
@ -142,15 +143,17 @@
"this_month": "Ин моҳ", "this_month": "Ин моҳ",
"all_time": "Тамоми давра", "all_time": "Тамоми давра",
"invoices": "фактура", "invoices": "фактура",
"active_skus": "SKU-ҳои фаъол", "active_skus": "Қисмҳои фаъол",
"units_on_hand": "Дар анбор", "units_on_hand": "Дар анбор",
"cost_value": "Арзиш (бо нархи харид)", "cost_value": "Арзиш (бо нархи харид)",
"sale_value": "Арзиш (бо нархи фурӯш)",
"low_stock": "Захираи кам", "low_stock": "Захираи кам",
"out_of_stock": "Тамом шуд", "out_of_stock": "Тамом шуд",
"top_parts": "Қисмҳои серфурӯш", "top_parts": "Қисмҳои серфурӯш",
"units_sold": "Фурӯхта шуд", "units_sold": "Фурӯхта шуд",
"revenue": "Даромад", "revenue": "Даромад",
"sale": "Фурӯш",
"cog": "Арзиши мол",
"profit": "Фоида",
"recent_sales": "Фурӯшҳои охирин", "recent_sales": "Фурӯшҳои охирин",
"saved_at": "Сабт шуд", "saved_at": "Сабт шуд",
"lines": "Сатрҳо", "lines": "Сатрҳо",

View File

@ -1,20 +1,21 @@
import { randomUUID } from 'node:crypto';
import { getDb } from './db.js'; import { getDb } from './db.js';
// Columns the user can sort the parts list by. Anything else is ignored. // Columns the user can sort the parts list by. Anything else is ignored.
const SORTABLE = new Set([ const SORTABLE = new Set([
'sku', 'name_en', 'name_tg', 'quantity_on_hand', 'name_en', 'name_tg', 'quantity_on_hand',
'sale_price', 'cost_price', 'reorder_level', 'updated_at' 'sale_price', 'cost_price', 'reorder_level', 'updated_at'
]); ]);
export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = [] } = {}) { export function listParts({ q = '', sort = 'name_en', dir = 'asc', categoryIds = [] } = {}) {
const db = getDb(); const db = getDb();
const col = SORTABLE.has(sort) ? sort : 'sku'; const col = SORTABLE.has(sort) ? sort : 'name_en';
const order = dir === 'desc' ? 'DESC' : 'ASC'; const order = dir === 'desc' ? 'DESC' : 'ASC';
const where = []; const where = ['p.active = 1'];
const params = {}; const params = {};
if (q && q.trim()) { if (q && q.trim()) {
where.push(`(p.sku LIKE @q OR p.name_en LIKE @q OR p.name_tg LIKE @q OR p.barcode LIKE @q)`); where.push(`(p.name_en LIKE @q OR p.name_tg LIKE @q OR p.barcode LIKE @q)`);
params.q = `%${q.trim()}%`; params.q = `%${q.trim()}%`;
} }
if (categoryIds && categoryIds.length) { if (categoryIds && categoryIds.length) {
@ -22,7 +23,7 @@ export function listParts({ q = '', sort = 'sku', dir = 'asc', categoryIds = []
where.push(`p.category_id IN (${placeholders})`); where.push(`p.category_id IN (${placeholders})`);
categoryIds.forEach((id, i) => { params[`cat${i}`] = id; }); categoryIds.forEach((id, i) => { params[`cat${i}`] = id; });
} }
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; const whereSql = `WHERE ${where.join(' AND ')}`;
const sql = ` const sql = `
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg
@ -59,6 +60,7 @@ export function categoriesWithParts() {
return getDb().prepare(` return getDb().prepare(`
SELECT c.* FROM categories c SELECT c.* FROM categories c
JOIN parts p ON p.category_id = c.id JOIN parts p ON p.category_id = c.id
WHERE p.active = 1
GROUP BY c.id GROUP BY c.id
ORDER BY c.sort_order, c.name_en ORDER BY c.sort_order, c.name_en
`).all(); `).all();
@ -66,7 +68,7 @@ export function categoriesWithParts() {
export function createPart(input) { export function createPart(input) {
const db = getDb(); const db = getDb();
const stmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO parts INSERT INTO parts
(sku, name_en, name_tg, description_en, description_tg, (sku, name_en, name_tg, description_en, description_tg,
category_id, unit, cost_price, sale_price, category_id, unit, cost_price, sale_price,
@ -76,15 +78,28 @@ export function createPart(input) {
@category_id, @unit, @cost_price, @sale_price, @category_id, @unit, @cost_price, @sale_price,
@quantity_on_hand, @reorder_level, @location, @barcode, @active) @quantity_on_hand, @reorder_level, @location, @barcode, @active)
`); `);
const result = stmt.run(normalizePart(input)); const stampStmt = db.prepare(`UPDATE parts SET sku = 'SKU-' || id WHERE id = ?`);
return result.lastInsertRowid;
// SKU is hidden from the UI; the user never types one. The column is still
// NOT NULL UNIQUE, so insert with a uuid placeholder and rewrite to SKU-{id}
// once we know the row id.
const tx = db.transaction((data) => {
const userSku = (data.sku || '').trim();
const sku = userSku || `__pending__${randomUUID()}`;
const result = insertStmt.run({ ...data, sku });
const id = result.lastInsertRowid;
if (!userSku) stampStmt.run(id);
return id;
});
return tx(normalizePart(input));
} }
export function updatePart(id, input) { export function updatePart(id, input) {
const db = getDb(); const db = getDb();
// SKU is intentionally NOT updated here — it's hidden from the UI and frozen
// after creation (auto-stamped as `SKU-{id}` in createPart).
const stmt = db.prepare(` const stmt = db.prepare(`
UPDATE parts SET UPDATE parts SET
sku = @sku,
name_en = @name_en, name_en = @name_en,
name_tg = @name_tg, name_tg = @name_tg,
description_en = @description_en, description_en = @description_en,
@ -132,11 +147,17 @@ function toDirams(value) {
return Math.round(num * 100); return Math.round(num * 100);
} }
export function deactivatePart(id) {
getDb()
.prepare(`UPDATE parts SET active = 0, updated_at = datetime('now') WHERE id = ?`)
.run(Number(id));
}
export function lowStockParts(limit = 10) { export function lowStockParts(limit = 10) {
return getDb().prepare(` return getDb().prepare(`
SELECT * FROM parts SELECT * FROM parts
WHERE active = 1 AND quantity_on_hand <= reorder_level WHERE active = 1 AND quantity_on_hand <= reorder_level
ORDER BY (quantity_on_hand - reorder_level) ASC, sku ASC ORDER BY (quantity_on_hand - reorder_level) ASC, name_en ASC
LIMIT ? LIMIT ?
`).all(limit); `).all(limit);
} }

View File

@ -1,45 +1,38 @@
import { getDb } from './db.js'; import { getDb } from './db.js';
// All time windows are computed in local time using SQLite's `datetime('now', 'localtime')`. // All time windows are computed in Tajikistan time (UTC+5) while timestamps are stored as UTC.
// 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() { export function salesSummary() {
const db = getDb(); return {
const row = db.prepare(` all_time: windowStats(''),
SELECT today: windowStats(`date(saved_at, '+5 hours') = date('now', '+5 hours')`),
COUNT(*) AS invoice_count, week: windowStats(`date(saved_at, '+5 hours') >= date('now', '+5 hours', '-6 days')`),
COALESCE(SUM(total_dirams), 0) AS total_dirams month: windowStats(`strftime('%Y-%m', saved_at, '+5 hours') = strftime('%Y-%m', 'now', '+5 hours')`)
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 };
} }
export function topSellingParts(limit = 10) { export function topSellingParts(limit = 10) {
@ -47,13 +40,15 @@ export function topSellingParts(limit = 10) {
SELECT SELECT
p.id, p.sku, p.name_en, p.name_tg, p.id, p.sku, p.name_en, p.name_tg,
SUM(l.quantity) AS units_sold, 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 FROM invoice_lines l
JOIN invoices i ON i.id = l.invoice_id JOIN invoices i ON i.id = l.invoice_id
JOIN parts p ON p.id = l.part_id JOIN parts p ON p.id = l.part_id
WHERE i.status = 'saved' AND l.affects_inventory = 1 WHERE i.status = 'saved' AND l.affects_inventory = 1
GROUP BY p.id GROUP BY p.id
ORDER BY units_sold DESC, revenue_dirams DESC ORDER BY profit_dirams DESC, units_sold DESC
LIMIT ? LIMIT ?
`).all(limit); `).all(limit);
} }
@ -84,7 +79,12 @@ export function inventorySummary() {
export function recentSales(limit = 10) { export function recentSales(limit = 10) {
return getDb().prepare(` 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 (SELECT COUNT(*) FROM invoice_lines WHERE invoice_id = invoices.id) AS line_count
FROM invoices FROM invoices
WHERE status = 'saved' WHERE status = 'saved'

View File

@ -1,5 +1,5 @@
<script> <script>
import { locale, t, localized } from '$lib/i18n/store.js'; import { locale, t, localized, formatTs } from '$lib/i18n/store.js';
export let data; export let data;
$: lang = $locale; $: lang = $locale;
@ -15,7 +15,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('parts.quantity_on_hand')}</th> <th class="num">{$t('parts.quantity_on_hand')}</th>
<th class="num">{$t('parts.reorder_level')}</th> <th class="num">{$t('parts.reorder_level')}</th>
@ -24,8 +23,7 @@
<tbody> <tbody>
{#each lowStock as p} {#each lowStock as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td> <td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td class="num"><span class="pill low">{p.quantity_on_hand}</span></td> <td class="num"><span class="pill low">{p.quantity_on_hand}</span></td>
<td class="num">{p.reorder_level}</td> <td class="num">{p.reorder_level}</td>
</tr> </tr>
@ -43,7 +41,6 @@
<tr> <tr>
<th>{$t('movements.created_at')}</th> <th>{$t('movements.created_at')}</th>
<th>{$t('movements.type')}</th> <th>{$t('movements.type')}</th>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('movements.quantity')}</th> <th class="num">{$t('movements.quantity')}</th>
</tr> </tr>
@ -51,10 +48,9 @@
<tbody> <tbody>
{#each movements as m} {#each movements as m}
<tr> <tr>
<td>{m.created_at}</td> <td>{formatTs(m.created_at)}</td>
<td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td> <td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td>
<td><a href="/parts/{m.part_id}">{m.sku}</a></td> <td><a href="/parts/{m.part_id}">{localized(m, 'name', lang)}</a></td>
<td>{localized(m, 'name', lang)}</td>
<td class="num">{m.quantity}</td> <td class="num">{m.quantity}</td>
</tr> </tr>
{/each} {/each}

View File

@ -1,53 +1,35 @@
<script> <script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js'; import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data; export let data;
$: lang = $locale; $: lang = $locale;
$: ({ sales, topParts, inventory, recentSales } = data); $: ({ sales, topParts, inventory, recentSales } = data);
function formatWhen(iso) {
if (!iso) return '';
const d = new Date(iso.replace(' ', 'T') + 'Z');
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script> </script>
<h2>{$t('reports.sales_heading')}</h2> <h2>{$t('reports.sales_heading')}</h2>
<div class="grid"> <div class="grid">
<div class="card stat"> {#each [
<div class="label">{$t('reports.today')}</div> { label: $t('reports.today'), row: sales.today },
<div class="value"> { label: $t('reports.last_7_days'), row: sales.week },
{formatMoney(sales.today.total_dirams, lang)} { label: $t('reports.this_month'), row: sales.month },
<span class="cur">{$t('common.currency_short')}</span> { label: $t('reports.all_time'), row: sales.all_time }
] as card}
<div class="card stat">
<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="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="sub">{card.row.invoice_count} {$t('reports.invoices')}</div>
</div> </div>
<div class="sub">{sales.today.invoice_count} {$t('reports.invoices')}</div> {/each}
</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>
</div> </div>
<h2>{$t('reports.inventory_heading')}</h2> <h2>{$t('reports.inventory_heading')}</h2>
@ -68,13 +50,7 @@
<span class="cur">{$t('common.currency_short')}</span> <span class="cur">{$t('common.currency_short')}</span>
</div> </div>
</div> </div>
<div class="card stat"> <div></div>
<div class="label">{$t('reports.sale_value')}</div>
<div class="value">
{formatMoney(inventory.sale_value_dirams, lang)}
<span class="cur">{$t('common.currency_short')}</span>
</div>
</div>
<div class="card stat"> <div class="card stat">
<div class="label">{$t('reports.low_stock')}</div> <div class="label">{$t('reports.low_stock')}</div>
<div class="value" class:warn={inventory.lowStockCount > 0}>{inventory.lowStockCount}</div> <div class="value" class:warn={inventory.lowStockCount > 0}>{inventory.lowStockCount}</div>
@ -92,20 +68,28 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th>{$t('parts.sku')}</th>
<th>{$t('parts.name')}</th> <th>{$t('parts.name')}</th>
<th class="num">{$t('reports.units_sold')}</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> </tr>
</thead> </thead>
<tbody> <tbody>
{#each topParts as p} {#each topParts as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td> <td><a href="/parts/{p.id}">{localized(p, 'name', lang)}</a></td>
<td>{localized(p, 'name', lang)}</td>
<td class="num">{p.units_sold}</td> <td class="num">{p.units_sold}</td>
<td class="num"> <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> <span class="cur">{$t('common.currency_short')}</span>
</td> </td>
</tr> </tr>
@ -123,17 +107,27 @@
<tr> <tr>
<th>{$t('reports.saved_at')}</th> <th>{$t('reports.saved_at')}</th>
<th class="num">{$t('reports.lines')}</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> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each recentSales as s} {#each recentSales as s}
<tr> <tr>
<td>{formatWhen(s.saved_at)}</td> <td>{formatTs(s.saved_at)}</td>
<td class="num">{s.line_count}</td> <td class="num">{s.line_count}</td>
<td class="num"> <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> <span class="cur">{$t('common.currency_short')}</span>
</td> </td>
<td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td> <td><a href="/invoices/{s.id}">{$t('reports.view')}</a></td>
@ -146,7 +140,7 @@
<style> <style>
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@ -159,5 +153,49 @@
} }
.stat .value.warn { color: #b8443f; } .stat .value.warn { color: #b8443f; }
.stat .cur { font-size: 0.8rem; color: #6b7388; margin-left: 0.2rem; } .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> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js'; import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data; export let data;
$: lang = $locale; $: lang = $locale;
@ -7,7 +7,7 @@
function lineLabel(line) { function lineLabel(line) {
if (line.affects_inventory === 0) return line.label; if (line.affects_inventory === 0) return line.label;
return `${line.part_sku}${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`; return localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang);
} }
</script> </script>
@ -15,7 +15,7 @@
<header class="head"> <header class="head">
<div> <div>
<h1>{$t('invoices.saved_title')} #{invoice.id}</h1> <h1>{$t('invoices.saved_title')} #{invoice.id}</h1>
<p class="muted">{invoice.saved_at}</p> <p class="muted">{formatTs(invoice.saved_at)}</p>
</div> </div>
<a href="/invoices/new" class="print-hide back">{$t('invoices.new_another')}</a> <a href="/invoices/new" class="print-hide back">{$t('invoices.new_another')}</a>
</header> </header>

View File

@ -19,7 +19,6 @@
const q = partSearch.trim().toLowerCase(); const q = partSearch.trim().toLowerCase();
if (!q) return parts; if (!q) return parts;
return parts.filter((p) => return parts.filter((p) =>
(p.sku || '').toLowerCase().includes(q) ||
(p.name_en || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) ||
(p.name_tg || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) ||
(p.barcode || '').toLowerCase().includes(q) (p.barcode || '').toLowerCase().includes(q)
@ -59,7 +58,7 @@
function lineLabel(line) { function lineLabel(line) {
if (line.affects_inventory === 0) return line.label; if (line.affects_inventory === 0) return line.label;
return `${line.part_sku}${localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang)}`; return localized({ name_en: line.part_name_en, name_tg: line.part_name_tg }, 'name', lang);
} }
function confirmCancel(event) { function confirmCancel(event) {
@ -88,7 +87,7 @@
<option value=""></option> <option value=""></option>
{#each visibleParts as p} {#each visibleParts as p}
<option value={String(p.id)}> <option value={String(p.id)}>
{p.sku}{localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand}) {localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
</option> </option>
{/each} {/each}
</select> </select>

View File

@ -21,7 +21,6 @@
const q = partSearch.trim().toLowerCase(); const q = partSearch.trim().toLowerCase();
if (!q) return parts; if (!q) return parts;
return parts.filter((p) => return parts.filter((p) =>
(p.sku || '').toLowerCase().includes(q) ||
(p.name_en || '').toLowerCase().includes(q) || (p.name_en || '').toLowerCase().includes(q) ||
(p.name_tg || '').toLowerCase().includes(q) || (p.name_tg || '').toLowerCase().includes(q) ||
(p.barcode || '').toLowerCase().includes(q) (p.barcode || '').toLowerCase().includes(q)
@ -105,7 +104,7 @@
<option value=""></option> <option value=""></option>
{#each visibleParts as p} {#each visibleParts as p}
<option value={String(p.id)}> <option value={String(p.id)}>
{p.sku}{localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand}) {localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
</option> </option>
{/each} {/each}
</select> </select>

View File

@ -2,7 +2,7 @@ import { listParts, categoriesWithParts } from '$lib/server/parts.js';
export function load({ url }) { export function load({ url }) {
const q = url.searchParams.get('q') ?? ''; const q = url.searchParams.get('q') ?? '';
const sort = url.searchParams.get('sort') ?? 'sku'; const sort = url.searchParams.get('sort') ?? 'name_en';
const dir = url.searchParams.get('dir') ?? 'asc'; const dir = url.searchParams.get('dir') ?? 'asc';
const cat = url.searchParams.get('category') ?? ''; const cat = url.searchParams.get('category') ?? '';
const categoryIds = cat const categoryIds = cat

View File

@ -15,7 +15,7 @@
const params = new URLSearchParams(); const params = new URLSearchParams();
if (qNext) params.set('q', qNext); if (qNext) params.set('q', qNext);
if (catsNext.length) params.set('category', catsNext.join(',')); if (catsNext.length) params.set('category', catsNext.join(','));
if (sortNext && sortNext !== 'sku') params.set('sort', sortNext); if (sortNext && sortNext !== 'name_en') params.set('sort', sortNext);
if (dirNext && dirNext !== 'asc') params.set('dir', dirNext); if (dirNext && dirNext !== 'asc') params.set('dir', dirNext);
const target = '/parts' + (params.toString() ? '?' + params.toString() : ''); const target = '/parts' + (params.toString() ? '?' + params.toString() : '');
goto(target, { replaceState: true, keepFocus: true, noScroll: true }); goto(target, { replaceState: true, keepFocus: true, noScroll: true });
@ -99,7 +99,6 @@
<table> <table>
<thead> <thead>
<tr> <tr>
<th><button class="th-btn" on:click={() => sortBy('sku')}>{$t('parts.sku')} {arrow('sku')}</button></th>
<th><button class="th-btn" on:click={() => sortBy(lang === 'tg' ? 'name_tg' : 'name_en')}>{$t('parts.name')} {arrow(lang === 'tg' ? 'name_tg' : 'name_en')}</button></th> <th><button class="th-btn" on:click={() => sortBy(lang === 'tg' ? 'name_tg' : 'name_en')}>{$t('parts.name')} {arrow(lang === 'tg' ? 'name_tg' : 'name_en')}</button></th>
<th>{$t('parts.category')}</th> <th>{$t('parts.category')}</th>
<th class="num"><button class="th-btn" on:click={() => sortBy('quantity_on_hand')}>{$t('parts.quantity_on_hand')} {arrow('quantity_on_hand')}</button></th> <th class="num"><button class="th-btn" on:click={() => sortBy('quantity_on_hand')}>{$t('parts.quantity_on_hand')} {arrow('quantity_on_hand')}</button></th>
@ -110,9 +109,8 @@
<tbody> <tbody>
{#each parts as p} {#each parts as p}
<tr> <tr>
<td><a href="/parts/{p.id}">{p.sku}</a></td>
<td> <td>
{localized(p, 'name', lang)} <a href="/parts/{p.id}">{localized(p, 'name', lang)}</a>
{#if !hasTranslation(p, 'name', lang)} {#if !hasTranslation(p, 'name', lang)}
<em class="missing">{$t('common.missing_translation')}</em> <em class="missing">{$t('common.missing_translation')}</em>
{/if} {/if}

View File

@ -1,5 +1,5 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { getPart, getPartBySku, listCategories, updatePart } from '$lib/server/parts.js'; import { deactivatePart, getPart, listCategories, updatePart } from '$lib/server/parts.js';
import { recentMovementsForPart } from '$lib/server/movements.js'; import { recentMovementsForPart } from '$lib/server/movements.js';
export function load({ params }) { export function load({ params }) {
@ -14,21 +14,23 @@ export function load({ params }) {
} }
export const actions = { export const actions = {
default: async ({ request, params }) => { update: async ({ request, params }) => {
const id = Number(params.id); const id = Number(params.id);
const form = await request.formData(); const form = await request.formData();
const data = Object.fromEntries(form); const data = Object.fromEntries(form);
const errors = {}; const errors = {};
if (!data.sku || !data.sku.trim()) errors.sku = 'parts.errors.sku_required';
if ((!data.name_en || !data.name_en.trim()) && (!data.name_tg || !data.name_tg.trim())) { if ((!data.name_en || !data.name_en.trim()) && (!data.name_tg || !data.name_tg.trim())) {
errors.name = 'parts.errors.name_required'; errors.name = 'parts.errors.name_required';
} }
const existing = getPartBySku(data.sku.trim());
if (existing && existing.id !== id) errors.sku = 'parts.errors.sku_taken';
if (Object.keys(errors).length) return fail(400, { errors, values: data }); if (Object.keys(errors).length) return fail(400, { errors, values: data });
updatePart(id, data); updatePart(id, data);
throw redirect(303, `/parts/${id}`); throw redirect(303, `/parts/${id}`);
},
delete: async ({ params }) => {
deactivatePart(Number(params.id));
throw redirect(303, '/parts');
} }
}; };

View File

@ -1,11 +1,18 @@
<script> <script>
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js'; import { enhance } from '$app/forms';
import { locale, t, localized, formatMoney, formatTs } from '$lib/i18n/store.js';
export let data; export let data;
export let form; export let form;
$: lang = $locale; $: lang = $locale;
$: ({ part, categories, movements } = data); $: ({ part, categories, movements } = data);
function confirmDelete(event) {
const name = localized(part, 'name', lang) || String(part.id);
const message = $t('parts.delete_confirm').replace('{name}', name);
if (!confirm(message)) event.preventDefault();
}
$: errors = form?.errors ?? {}; $: errors = form?.errors ?? {};
$: values = form?.values ?? {}; $: values = form?.values ?? {};
@ -17,7 +24,7 @@
</script> </script>
<div class="page-head"> <div class="page-head">
<h1>{$t('parts.edit')}: {part.sku}</h1> <h1>{$t('parts.edit')}: {localized(part, 'name', lang) || part.id}</h1>
<a href="/parts" class="muted">{$t('common.back')}</a> <a href="/parts" class="muted">{$t('common.back')}</a>
</div> </div>
@ -27,13 +34,7 @@
<div class="layout"> <div class="layout">
<section> <section>
<form class="stack" method="POST"> <form class="stack" method="POST" action="?/update">
<label>
{$t('parts.sku')} *
<input name="sku" required value={values.sku ?? part.sku} />
{#if errors.sku}<span class="field-error">{$t(errors.sku)}</span>{/if}
</label>
<div class="row"> <div class="row">
<label> <label>
{$t('parts.name_en')} {$t('parts.name_en')}
@ -83,27 +84,10 @@
</label> </label>
</div> </div>
<div class="row"> <label>
<label> {$t('parts.barcode')}
{$t('parts.location')} <input name="barcode" value={values.barcode ?? part.barcode ?? ''} />
<input name="location" value={values.location ?? part.location ?? ''} /> </label>
</label>
<label>
{$t('parts.barcode')}
<input name="barcode" value={values.barcode ?? part.barcode ?? ''} />
</label>
</div>
<div class="row">
<label>
{$t('parts.description_en')}
<textarea name="description_en">{values.description_en ?? part.description_en ?? ''}</textarea>
</label>
<label>
{$t('parts.description_tg')}
<textarea name="description_tg">{values.description_tg ?? part.description_tg ?? ''}</textarea>
</label>
</div>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" name="active" value="1" <input type="checkbox" name="active" value="1"
@ -116,6 +100,10 @@
<a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a> <a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a>
</div> </div>
</form> </form>
<form method="POST" action="?/delete" class="delete-form" use:enhance on:submit={confirmDelete}>
<button type="submit" class="danger">{$t('common.delete')}</button>
</form>
</section> </section>
<aside> <aside>
@ -129,8 +117,8 @@
{$t('parts.reorder_level')}: {part.reorder_level} {$t('parts.reorder_level')}: {part.reorder_level}
</div> </div>
<hr /> <hr />
<div class="muted small">{$t('common.created')}: {part.created_at}</div> <div class="muted small">{$t('common.created')}: {formatTs(part.created_at)}</div>
<div class="muted small">{$t('common.updated')}: {part.updated_at}</div> <div class="muted small">{$t('common.updated')}: {formatTs(part.updated_at)}</div>
</div> </div>
<h2>{$t('parts.recent_movements')}</h2> <h2>{$t('parts.recent_movements')}</h2>
@ -149,7 +137,7 @@
<tbody> <tbody>
{#each movements as m} {#each movements as m}
<tr> <tr>
<td>{m.created_at}</td> <td>{formatTs(m.created_at)}</td>
<td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td> <td><span class="pill">{$t('movements.type_' + m.movement_type)}</span></td>
<td class="num">{m.quantity > 0 ? '+' : ''}{m.quantity}</td> <td class="num">{m.quantity > 0 ? '+' : ''}{m.quantity}</td>
<td class="num">{m.unit_price != null ? formatMoney(m.unit_price, lang) : $t('common.none')}</td> <td class="num">{m.unit_price != null ? formatMoney(m.unit_price, lang) : $t('common.none')}</td>
@ -186,6 +174,11 @@
.btn-link:hover { background: #00553e; color: #fff; } .btn-link:hover { background: #00553e; color: #fff; }
.checkbox { display: flex; align-items: center; gap: 0.4rem; } .checkbox { display: flex; align-items: center; gap: 0.4rem; }
.field-error { color: #8a1f1b; font-size: 0.8rem; } .field-error { color: #8a1f1b; font-size: 0.8rem; }
.delete-form {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #eef0f5;
}
.qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; } .qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; }
.qty.low { color: #b8443f; } .qty.low { color: #b8443f; }

View File

@ -1,5 +1,5 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { createPart, getPartBySku, listCategories } from '$lib/server/parts.js'; import { createPart, listCategories } from '$lib/server/parts.js';
import { recordMovement } from '$lib/server/movements.js'; import { recordMovement } from '$lib/server/movements.js';
export function load() { export function load() {
@ -13,10 +13,6 @@ export const actions = {
const errors = validate(data); const errors = validate(data);
if (errors) return fail(400, { errors, values: data }); if (errors) return fail(400, { errors, values: data });
if (getPartBySku(data.sku.trim())) {
return fail(400, { errors: { sku: 'parts.errors.sku_taken' }, values: data });
}
// Save the part with quantity 0, then record an opening "in" movement // Save the part with quantity 0, then record an opening "in" movement
// if the user supplied an initial quantity. This keeps quantity changes // if the user supplied an initial quantity. This keeps quantity changes
// funneled exclusively through stock_movements. // funneled exclusively through stock_movements.
@ -38,7 +34,6 @@ export const actions = {
function validate(d) { function validate(d) {
const errors = {}; const errors = {};
if (!d.sku || !d.sku.trim()) errors.sku = 'parts.errors.sku_required';
if ((!d.name_en || !d.name_en.trim()) && (!d.name_tg || !d.name_tg.trim())) { if ((!d.name_en || !d.name_en.trim()) && (!d.name_tg || !d.name_tg.trim())) {
errors.name = 'parts.errors.name_required'; errors.name = 'parts.errors.name_required';
} }

View File

@ -17,12 +17,6 @@
{/if} {/if}
<form class="stack" method="POST"> <form class="stack" method="POST">
<label>
{$t('parts.sku')} *
<input name="sku" required value={values.sku ?? ''} />
{#if errors.sku}<span class="field-error">{$t(errors.sku)}</span>{/if}
</label>
<div class="row"> <div class="row">
<label> <label>
{$t('parts.name_en')} {$t('parts.name_en')}
@ -60,7 +54,7 @@
<div class="row"> <div class="row">
<label> <label>
{$t('parts.unit')} {$t('parts.unit')}
<input name="unit" value={values.unit ?? 'pcs'} /> <input name="unit" value={values.unit ?? 'liter'} />
</label> </label>
<label> <label>
{$t('parts.reorder_level')} {$t('parts.reorder_level')}
@ -74,24 +68,8 @@
<input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} /> <input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} />
</label> </label>
<label> <label>
{$t('parts.location')} {$t('parts.barcode')}
<input name="location" value={values.location ?? ''} /> <input name="barcode" value={values.barcode ?? ''} />
</label>
</div>
<label>
{$t('parts.barcode')}
<input name="barcode" value={values.barcode ?? ''} />
</label>
<div class="row">
<label>
{$t('parts.description_en')}
<textarea name="description_en">{values.description_en ?? ''}</textarea>
</label>
<label>
{$t('parts.description_tg')}
<textarea name="description_tg">{values.description_tg ?? ''}</textarea>
</label> </label>
</div> </div>

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB