Compare commits
13 Commits
83a59f1677
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0314e87c3f | |||
| 32331d4cf8 | |||
| a85979731f | |||
| 45ef55e13e | |||
| 0000748fe0 | |||
| a287d26b93 | |||
| d4cba18017 | |||
| 259f8d4b8f | |||
| 66e15dee1f | |||
| aac71becfc | |||
| 8cbaa55b48 | |||
| 82bb456103 | |||
| d5a80bb104 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||||
|
|||||||
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
|
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/"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
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
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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())}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -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": "Сатрҳо",
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Reference in New Issue
Block a user