Initial scaffold for AvtoAmbor parts inventory
SvelteKit 2 + Svelte 4 + adapter-node, SQLite via better-sqlite3 (WAL, foreign keys on). Bilingual EN/Тоҷикӣ throughout, locale persisted in localStorage. Pages: dashboard (totals, low stock, recent movements), parts list with search and sort, part create/edit, record movement (in/out/adjust with smart unit-price and adjust-quantity prefill), suppliers list with inline add. Schema: categories, suppliers, parts (with _en/_tg name+description columns, dirams for money), stock_movements with check on movement_type. On-hand updates are done in JS inside a transaction with the movement insert. Dockerized dev: docker compose, named project, bind-mounted data/ for DB persistence. Seed contains 6 categories, 4 suppliers, 31 realistic parts (Lada / Nexia / Opel / Toyota bias). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
data
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
*.swp
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
.svelte-kit/
|
||||
build/
|
||||
data/
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.sw?
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
# Tools needed to compile better-sqlite3
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as the node user (uid 1000) — already exists in node images
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5173 3000
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
56
Makefile
Normal file
56
Makefile
Normal file
@ -0,0 +1,56 @@
|
||||
.PHONY: help install run build db-init db-reset docker-build docker-shell clean clean-all
|
||||
|
||||
DC := docker compose
|
||||
|
||||
help:
|
||||
@echo ""
|
||||
@echo " ╔════════════════════════════════════════════════╗"
|
||||
@echo " ║ AvtoAmbor — auto parts inventory (dev tasks) ║"
|
||||
@echo " ╚════════════════════════════════════════════════╝"
|
||||
@echo ""
|
||||
@echo " make install Install npm dependencies inside the container"
|
||||
@echo " make run Start the dev server (http://localhost:5173)"
|
||||
@echo " make build Production build into ./build (adapter-node)"
|
||||
@echo " make db-init Create data/avtoambor.db from schema + seed (skip if exists)"
|
||||
@echo " make db-reset DELETE and recreate data/avtoambor.db (asks first)"
|
||||
@echo " make docker-build Rebuild the Docker image"
|
||||
@echo " make docker-shell Open an interactive bash shell in the container"
|
||||
@echo " make clean Remove node_modules and build/ (keeps data/)"
|
||||
@echo " make clean-all Also wipe data/ (destroys the DB)"
|
||||
@echo ""
|
||||
|
||||
install:
|
||||
@$(DC) run --rm app npm install
|
||||
|
||||
run:
|
||||
@$(DC) up
|
||||
|
||||
build:
|
||||
@$(DC) run --rm app npm run build
|
||||
|
||||
db-init:
|
||||
@if [ -f data/avtoambor.db ]; then \
|
||||
echo "data/avtoambor.db already exists — skipping. Use 'make db-reset' to recreate."; \
|
||||
else \
|
||||
mkdir -p data && $(DC) run --rm app node scripts/init-db.js; \
|
||||
fi
|
||||
|
||||
db-reset:
|
||||
@printf "This will DELETE data/avtoambor.db. Continue? [y/N] " && read ans && [ "$$ans" = "y" ] || (echo "aborted." && exit 1)
|
||||
@rm -f data/avtoambor.db data/avtoambor.db-shm data/avtoambor.db-wal
|
||||
@mkdir -p data
|
||||
@$(DC) run --rm app node scripts/init-db.js
|
||||
|
||||
docker-build:
|
||||
@$(DC) build
|
||||
|
||||
docker-shell:
|
||||
@$(DC) run --rm app bash
|
||||
|
||||
clean:
|
||||
@rm -rf node_modules build .svelte-kit
|
||||
@echo "removed node_modules, build/, .svelte-kit/ (data/ kept)"
|
||||
|
||||
clean-all: clean
|
||||
@rm -rf data
|
||||
@echo "wiped data/ as well."
|
||||
42
README.md
Normal file
42
README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# AvtoAmbor
|
||||
|
||||
Simple auto-parts inventory for a single shop. SvelteKit + SQLite. UI is
|
||||
bilingual (English / Тоҷикӣ).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker + Docker Compose
|
||||
|
||||
Everything else (Node, npm, native build tools for `better-sqlite3`) runs
|
||||
inside the container.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
make install # install dependencies inside the container
|
||||
make db-init # create data/avtoambor.db with schema + seed
|
||||
make run # dev server at http://localhost:5173
|
||||
```
|
||||
|
||||
`make help` lists every target.
|
||||
|
||||
## Production
|
||||
|
||||
Build with `make build`, copy the `build/` directory plus `node_modules` and
|
||||
`data/avtoambor.db` to the Windows host, then run:
|
||||
|
||||
```sh
|
||||
node build/index.js
|
||||
```
|
||||
|
||||
The server listens on port 3000 by default. Open `http://localhost:3000` in
|
||||
the browser on that machine.
|
||||
|
||||
## Data
|
||||
|
||||
The SQLite database lives at `data/avtoambor.db`. The `data/` directory is
|
||||
gitignored and bind-mounted into the container, so the DB file persists on
|
||||
the host.
|
||||
|
||||
- `make db-init` — create the DB if it doesn't exist (does nothing otherwise).
|
||||
- `make db-reset` — delete and recreate the DB (asks for confirmation).
|
||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@ -0,0 +1,18 @@
|
||||
name: avtoambor
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
image: avtoambor:dev
|
||||
container_name: avtoambor
|
||||
working_dir: /app
|
||||
user: node
|
||||
ports:
|
||||
- "5173:5173"
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: npm run dev
|
||||
2201
package-lock.json
generated
Normal file
2201
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "avtoambor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 5173",
|
||||
"build": "vite build",
|
||||
"preview": "node build/index.js",
|
||||
"start": "node build/index.js",
|
||||
"db:init": "node scripts/init-db.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"svelte": "^4.2.0",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.3.0"
|
||||
}
|
||||
}
|
||||
167
prompts.txt
Normal file
167
prompts.txt
Normal file
@ -0,0 +1,167 @@
|
||||
|
||||
# AvtoAmbor — Auto Parts Inventory System (v1 scaffold)
|
||||
|
||||
## Context
|
||||
Build a simple, single-user inventory system named "AvtoAmbor" for an auto parts
|
||||
store in Tajikistan. Development is on Linux inside Docker; the production target
|
||||
is a Windows machine where the owner will access it via browser on localhost.
|
||||
v1 is PARTS ONLY — no service jobs, customers, or invoicing.
|
||||
|
||||
Do not write tests, lint config, or CI in this pass. Keep the code idiomatic,
|
||||
lightly commented, and not over-engineered.
|
||||
|
||||
## Tech stack (strict)
|
||||
- Node.js 20 LTS, run via Docker
|
||||
- SQLite via `better-sqlite3`
|
||||
- SvelteKit 2.x pinned to **Svelte 4** — `"svelte": "^4.2.0"` in package.json.
|
||||
Use Svelte 4 syntax only: no runes, no `$state`/`$derived`/`$effect`.
|
||||
- `@sveltejs/adapter-node` for production (so deployment to Windows is just
|
||||
`node build/index.js`)
|
||||
- Plain CSS. No Tailwind, no component library.
|
||||
|
||||
## Repo layout
|
||||
avtoambor/
|
||||
Makefile
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.gitignore
|
||||
README.md
|
||||
package.json
|
||||
svelte.config.js
|
||||
vite.config.js
|
||||
src/
|
||||
app.html
|
||||
hooks.server.js # opens db on startup
|
||||
lib/
|
||||
server/
|
||||
db.js # better-sqlite3, WAL mode, foreign keys on
|
||||
schema.sql
|
||||
seed.sql
|
||||
parts.js # CRUD helpers
|
||||
movements.js
|
||||
suppliers.js
|
||||
i18n/
|
||||
en.json
|
||||
tg.json
|
||||
store.js # locale store + t(key) helper
|
||||
components/
|
||||
Header.svelte # "AvtoAmbor" wordmark + EN/Тоҷ toggle
|
||||
routes/
|
||||
+layout.svelte # renders <Header/>
|
||||
+page.svelte # dashboard
|
||||
parts/
|
||||
+page.svelte # list, search, sort
|
||||
+page.server.js
|
||||
new/
|
||||
+page.svelte
|
||||
+page.server.js
|
||||
[id]/
|
||||
+page.svelte # edit + recent movements
|
||||
+page.server.js
|
||||
movements/
|
||||
new/
|
||||
+page.svelte
|
||||
+page.server.js
|
||||
suppliers/
|
||||
+page.svelte
|
||||
+page.server.js
|
||||
data/ # gitignored; holds avtoambor.db
|
||||
scripts/
|
||||
init-db.js # reads schema.sql + seed.sql, writes data/avtoambor.db
|
||||
|
||||
## Database schema (initial guess — we will iterate)
|
||||
- All translated fields use `_en` and `_tg` suffixes.
|
||||
- Money stored as INTEGER dirams (1 TJS = 100 dirams).
|
||||
- Timestamps as ISO 8601 TEXT (`datetime('now')`).
|
||||
|
||||
Tables:
|
||||
- categories(id PK, name_en, name_tg, sort_order)
|
||||
- suppliers(id PK, name, phone, address, notes, created_at)
|
||||
- parts(id PK, sku UNIQUE NOT NULL, name_en, name_tg,
|
||||
description_en, description_tg, category_id FK,
|
||||
unit TEXT, cost_price INT, sale_price INT,
|
||||
quantity_on_hand INT DEFAULT 0, reorder_level INT DEFAULT 0,
|
||||
location TEXT, barcode TEXT, active INT DEFAULT 1,
|
||||
created_at, updated_at)
|
||||
- stock_movements(id PK, part_id FK, movement_type
|
||||
CHECK(movement_type IN ('in','out','adjust')),
|
||||
quantity INT, unit_price INT,
|
||||
supplier_id FK NULL, reference TEXT, notes TEXT,
|
||||
created_at)
|
||||
|
||||
Indexes on parts.sku, parts.barcode, parts.category_id, stock_movements.part_id.
|
||||
|
||||
Update `parts.quantity_on_hand` in application code (in a transaction with
|
||||
the movement insert), not via trigger — clearer for future-me.
|
||||
|
||||
## Seed data
|
||||
- 5–6 categories: Filters, Brakes, Engine, Electrical, Fluids, Belts & Hoses
|
||||
- 3–4 suppliers with realistic names
|
||||
- 25–30 realistic auto parts with EN and Tajik (Cyrillic) names.
|
||||
Bias toward parts common for Lada, Daewoo Nexia, Opel, and Toyota,
|
||||
which are common in Tajikistan. Use realistic somoni prices.
|
||||
|
||||
## UI
|
||||
- `Header.svelte` (in `+layout.svelte`, every page):
|
||||
- Left: "AvtoAmbor" wordmark
|
||||
- Right: language toggle showing the *other* language (click EN → switches to
|
||||
Tajik). Persist choice to `localStorage` under key `avtoambor.locale`.
|
||||
- Pages for v1:
|
||||
- `/` dashboard: total SKUs, count of parts at/below reorder level,
|
||||
total inventory value at cost
|
||||
- `/parts` searchable + sortable list
|
||||
- `/parts/new` create
|
||||
- `/parts/[id]` edit, with recent movements panel
|
||||
- `/movements/new` record in/out/adjust
|
||||
- `/suppliers` list + add inline
|
||||
- Every visible string goes through the i18n helper. Missing keys fall back to
|
||||
English and log a `console.warn` once per missing key.
|
||||
GIve it a Tajik look & feel, if that's even possible for such a simple app
|
||||
|
||||
## i18n
|
||||
- `en.json` and `tg.json` with nested keys (e.g. `nav.parts`, `parts.sku`)
|
||||
- `store.js` exports a writable `locale` store and a derived `t` function:
|
||||
`$t('parts.sku')` in templates
|
||||
- Default locale is `'tg'`. On mount in the layout, read `localStorage` and
|
||||
hydrate if present.
|
||||
|
||||
## Makefile
|
||||
Targets (use `docker compose`). First target = `help`.
|
||||
- `help` — print a friendly banner + list of targets with descriptions
|
||||
- `install` — `docker compose run --rm app npm install`
|
||||
- `run` — `docker compose up` (dev server on 5173)
|
||||
- `build` — production build via adapter-node into `build/`
|
||||
- `db-init` — run `scripts/init-db.js`, skip if `data/avtoambor.db` exists
|
||||
- `db-reset` — confirm prompt, then delete and recreate the db
|
||||
- `docker-build` — build the image
|
||||
- `docker-shell` — interactive bash in the container
|
||||
- `clean` — remove `node_modules`, `build/`, but keep `data/`
|
||||
- `clean-all` — also wipe `data/`
|
||||
|
||||
Use `@` to suppress command echo where it would be noise.
|
||||
|
||||
## Dockerfile
|
||||
- `node:20-bookworm-slim`
|
||||
- Install `python3 make g++` for the `better-sqlite3` native build
|
||||
- Non-root user
|
||||
- WORKDIR `/app`
|
||||
- EXPOSE 5173 and 3000
|
||||
|
||||
## docker-compose.yml
|
||||
- One service `app`
|
||||
- Bind-mount the repo to `/app`
|
||||
- Named volume for `node_modules` so it doesn't shadow the host
|
||||
- Bind-mount `./data` so the SQLite file persists on the host
|
||||
- Map 5173:5173 and 3000:3000
|
||||
|
||||
## README.md
|
||||
Short: what it is, prerequisites (Docker), quickstart
|
||||
(`make install && make db-init && make run`), and a one-liner on production:
|
||||
`make build` then `node build/index.js` on the Windows host.
|
||||
|
||||
## Deliverables for this pass
|
||||
1. Generate every file above, working out of the box.
|
||||
2. Print the resulting file tree.
|
||||
3. Print the exact command sequence to bring it up from a fresh clone.
|
||||
4. Call out anything you guessed at that I should review before we move on.
|
||||
35
scripts/init-db.js
Normal file
35
scripts/init-db.js
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
// Build data/avtoambor.db from schema.sql + seed.sql.
|
||||
// Safe to run from anywhere — paths are resolved relative to this file.
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const DATA_DIR = resolve(ROOT, 'data');
|
||||
const DB_PATH = resolve(DATA_DIR, 'avtoambor.db');
|
||||
const SCHEMA = resolve(ROOT, 'src/lib/server/schema.sql');
|
||||
const SEED = resolve(ROOT, 'src/lib/server/seed.sql');
|
||||
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
console.log(`[init-db] applying schema → ${DB_PATH}`);
|
||||
db.exec(readFileSync(SCHEMA, 'utf8'));
|
||||
|
||||
const partsCount = db.prepare(`SELECT COUNT(*) AS n FROM parts`).get().n;
|
||||
if (partsCount === 0) {
|
||||
console.log('[init-db] inserting seed data…');
|
||||
db.exec(readFileSync(SEED, 'utf8'));
|
||||
} else {
|
||||
console.log(`[init-db] parts table already has ${partsCount} rows — skipping seed.`);
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log('[init-db] done.');
|
||||
13
src/app.html
Normal file
13
src/app.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#c8102e" />
|
||||
<title>AvtoAmbor</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
10
src/hooks.server.js
Normal file
10
src/hooks.server.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { getDb } from '$lib/server/db.js';
|
||||
|
||||
// Open (and warm) the database on server startup so the first request
|
||||
// doesn't pay the cost.
|
||||
getDb();
|
||||
|
||||
/** @type {import('@sveltejs/kit').Handle} */
|
||||
export async function handle({ event, resolve }) {
|
||||
return resolve(event);
|
||||
}
|
||||
98
src/lib/components/Header.svelte
Normal file
98
src/lib/components/Header.svelte
Normal file
@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import { locale, t, toggleLocale } from '$lib/i18n/store.js';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
$: lang = $locale;
|
||||
$: path = $page.url.pathname;
|
||||
|
||||
function isActive(prefix) {
|
||||
if (prefix === '/') return path === '/';
|
||||
return path === prefix || path.startsWith(prefix + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="header">
|
||||
<a class="brand" href="/">
|
||||
<span class="wordmark">
|
||||
{#if lang === 'tg'}АвтоАмбор{:else}AvtoAmbor{/if}
|
||||
</span>
|
||||
<span class="tagline">{$t('app.tagline')}</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav">
|
||||
<a href="/" class:active={isActive('/')}>{$t('nav.dashboard')}</a>
|
||||
<a href="/parts" class:active={isActive('/parts')}>{$t('nav.parts')}</a>
|
||||
<a href="/movements/new" class:active={isActive('/movements')}>{$t('nav.movements')}</a>
|
||||
<a href="/suppliers" class:active={isActive('/suppliers')}>{$t('nav.suppliers')}</a>
|
||||
</nav>
|
||||
|
||||
<button class="lang" type="button" on:click={toggleLocale} aria-label="Switch language">
|
||||
{lang === 'en' ? $t('lang.switch_to_tg') : $t('lang.switch_to_en')}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: linear-gradient(180deg, #c8102e 0%, #b00d27 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
border-bottom: 1px solid #8e0a1f;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
/* Cyrillic-friendly serif for a slightly more "Tajik" feel */
|
||||
font-family: "Noto Serif", "Times New Roman", Georgia, serif;
|
||||
}
|
||||
.tagline {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
font-style: italic;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-left: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
.nav a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.nav a:hover { opacity: 1; background: rgba(255,255,255,0.1); }
|
||||
.nav a.active {
|
||||
background: rgba(255,255,255,0.22);
|
||||
opacity: 1;
|
||||
}
|
||||
.lang {
|
||||
background: rgba(255,255,255,0.18);
|
||||
color: inherit;
|
||||
border: 1px solid rgba(255,255,255,0.45);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lang:hover { background: rgba(255,255,255,0.28); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header { flex-wrap: wrap; }
|
||||
.tagline { display: none; }
|
||||
.nav { order: 3; flex-basis: 100%; margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
113
src/lib/i18n/en.json
Normal file
113
src/lib/i18n/en.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "AvtoAmbor",
|
||||
"tagline": "Auto parts inventory"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"parts": "Parts",
|
||||
"movements": "Movements",
|
||||
"suppliers": "Suppliers",
|
||||
"new_part": "New part",
|
||||
"new_movement": "Record movement"
|
||||
},
|
||||
"lang": {
|
||||
"switch_to_tg": "Тоҷикӣ",
|
||||
"switch_to_en": "English"
|
||||
},
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"search": "Search",
|
||||
"clear": "Clear",
|
||||
"actions": "Actions",
|
||||
"back": "Back",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"loading": "Loading…",
|
||||
"none": "—",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"submit": "Submit",
|
||||
"created": "Created",
|
||||
"updated": "Updated",
|
||||
"value": "Value",
|
||||
"total": "Total",
|
||||
"currency_short": "TJS",
|
||||
"missing_translation": "(missing translation)"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"total_skus": "Total SKUs",
|
||||
"low_stock": "At or below reorder level",
|
||||
"inventory_value": "Inventory value (at cost)",
|
||||
"low_stock_list": "Low stock",
|
||||
"recent_movements": "Recent movements",
|
||||
"quick_actions": "Quick actions"
|
||||
},
|
||||
"parts": {
|
||||
"title": "Parts",
|
||||
"new": "New part",
|
||||
"edit": "Edit part",
|
||||
"sku": "SKU",
|
||||
"name": "Name",
|
||||
"name_en": "Name (English)",
|
||||
"name_tg": "Name (Tajik)",
|
||||
"description": "Description",
|
||||
"description_en": "Description (English)",
|
||||
"description_tg": "Description (Tajik)",
|
||||
"category": "Category",
|
||||
"unit": "Unit",
|
||||
"cost_price": "Cost price",
|
||||
"sale_price": "Sale price",
|
||||
"quantity_on_hand": "On hand",
|
||||
"reorder_level": "Reorder level",
|
||||
"location": "Location",
|
||||
"barcode": "Barcode",
|
||||
"active": "Active",
|
||||
"search_placeholder": "Search by SKU, name, or barcode…",
|
||||
"no_results": "No parts match your search.",
|
||||
"recent_movements": "Recent movements",
|
||||
"initial_quantity": "Initial quantity",
|
||||
"errors": {
|
||||
"sku_required": "SKU is required.",
|
||||
"name_required": "At least one name (English or Tajik) is required.",
|
||||
"sku_taken": "That SKU is already used."
|
||||
}
|
||||
},
|
||||
"movements": {
|
||||
"title": "Stock movements",
|
||||
"new": "Record movement",
|
||||
"type": "Type",
|
||||
"type_in": "In (receive)",
|
||||
"type_out": "Out (sale / use)",
|
||||
"type_adjust": "Adjust (set on-hand)",
|
||||
"part": "Part",
|
||||
"quantity": "Quantity",
|
||||
"unit_price": "Unit price",
|
||||
"supplier": "Supplier",
|
||||
"reference": "Reference",
|
||||
"notes": "Notes",
|
||||
"created_at": "When",
|
||||
"no_movements": "No movements recorded yet.",
|
||||
"errors": {
|
||||
"part_required": "Pick a part.",
|
||||
"quantity_required": "Quantity must be a positive whole number.",
|
||||
"not_enough_stock": "Not enough stock on hand."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Suppliers",
|
||||
"name": "Name",
|
||||
"phone": "Phone",
|
||||
"address": "Address",
|
||||
"notes": "Notes",
|
||||
"add": "Add supplier",
|
||||
"no_suppliers": "No suppliers yet.",
|
||||
"delete_confirm": "Delete this supplier?",
|
||||
"errors": {
|
||||
"name_required": "Supplier name is required."
|
||||
}
|
||||
}
|
||||
}
|
||||
87
src/lib/i18n/store.js
Normal file
87
src/lib/i18n/store.js
Normal file
@ -0,0 +1,87 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import en from './en.json';
|
||||
import tg from './tg.json';
|
||||
|
||||
const DICTS = { en, tg };
|
||||
const STORAGE_KEY = 'avtoambor.locale';
|
||||
const DEFAULT_LOCALE = 'tg';
|
||||
|
||||
// Warn at most once per missing key, so the console doesn't flood.
|
||||
const _warned = new Set();
|
||||
|
||||
function lookup(dict, key) {
|
||||
const parts = key.split('.');
|
||||
let v = dict;
|
||||
for (const p of parts) {
|
||||
if (v == null) return undefined;
|
||||
v = v[p];
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export const locale = writable(
|
||||
(browser && localStorage.getItem(STORAGE_KEY)) || DEFAULT_LOCALE
|
||||
);
|
||||
|
||||
if (browser) {
|
||||
locale.subscribe((value) => {
|
||||
try { localStorage.setItem(STORAGE_KEY, value); } catch { /* ignore */ }
|
||||
document.documentElement.setAttribute('lang', value);
|
||||
});
|
||||
}
|
||||
|
||||
export const t = derived(locale, ($locale) => {
|
||||
return (key) => {
|
||||
const primary = lookup(DICTS[$locale], key);
|
||||
if (primary != null) return primary;
|
||||
const fallback = lookup(DICTS.en, key);
|
||||
if (fallback != null) {
|
||||
if (!_warned.has(key)) {
|
||||
_warned.add(key);
|
||||
console.warn(`[i18n] missing "${key}" for locale "${$locale}"; using English.`);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
if (!_warned.has(key)) {
|
||||
_warned.add(key);
|
||||
console.warn(`[i18n] missing key "${key}"`);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
});
|
||||
|
||||
export function toggleLocale() {
|
||||
locale.update((v) => (v === 'en' ? 'tg' : 'en'));
|
||||
}
|
||||
|
||||
// Pick the right column from a record that has both _en and _tg fields,
|
||||
// e.g. localized(part, 'name', $locale) → part.name_tg or part.name_en.
|
||||
// Falls back to whichever language has content (not just English) so a
|
||||
// TG-only entry still renders for an EN viewer.
|
||||
export function localized(record, baseField, lang) {
|
||||
if (!record) return '';
|
||||
return (
|
||||
record[`${baseField}_${lang}`] ||
|
||||
record[`${baseField}_en`] ||
|
||||
record[`${baseField}_tg`] ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
// True if the record has a non-empty value in the requested language.
|
||||
// Used to flag "(missing translation)" when we had to fall back.
|
||||
export function hasTranslation(record, baseField, lang) {
|
||||
if (!record) return false;
|
||||
const v = record[`${baseField}_${lang}`];
|
||||
return v != null && String(v).trim() !== '';
|
||||
}
|
||||
|
||||
// Money helpers: dirams ↔ display string.
|
||||
export function formatMoney(dirams, lang = 'en') {
|
||||
if (dirams == null) return '';
|
||||
const n = Number(dirams) / 100;
|
||||
// Tajik uses comma decimal separator in everyday use; English uses period.
|
||||
const s = n.toFixed(2);
|
||||
return lang === 'tg' ? s.replace('.', ',') : s;
|
||||
}
|
||||
113
src/lib/i18n/tg.json
Normal file
113
src/lib/i18n/tg.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "AvtoAmbor",
|
||||
"tagline": "Захираи қисмҳои эҳтиётии мошин"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Лавҳаи асосӣ",
|
||||
"parts": "Қисмҳо",
|
||||
"movements": "Ҳаракатҳо",
|
||||
"suppliers": "Таъминкунандагон",
|
||||
"new_part": "Қисми нав",
|
||||
"new_movement": "Сабти ҳаракат"
|
||||
},
|
||||
"lang": {
|
||||
"switch_to_tg": "Тоҷикӣ",
|
||||
"switch_to_en": "English"
|
||||
},
|
||||
"common": {
|
||||
"save": "Захира",
|
||||
"cancel": "Бекор",
|
||||
"delete": "Нест кардан",
|
||||
"search": "Ҷустуҷӯ",
|
||||
"clear": "Пок кардан",
|
||||
"actions": "Амалҳо",
|
||||
"back": "Бозгашт",
|
||||
"yes": "Ҳа",
|
||||
"no": "Не",
|
||||
"loading": "Боркунӣ…",
|
||||
"none": "—",
|
||||
"edit": "Тағйир додан",
|
||||
"add": "Илова",
|
||||
"submit": "Тасдиқ",
|
||||
"created": "Сохта шуд",
|
||||
"updated": "Нав карда шуд",
|
||||
"value": "Арзиш",
|
||||
"total": "Ҳамагӣ",
|
||||
"currency_short": "сом.",
|
||||
"missing_translation": "(тарҷума нест)"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Лавҳаи асосӣ",
|
||||
"total_skus": "Ҳамаи SKU-ҳо",
|
||||
"low_stock": "Дар сатҳи фармоиш ё камтар",
|
||||
"inventory_value": "Арзиши захира (бо нархи харид)",
|
||||
"low_stock_list": "Захираи кам",
|
||||
"recent_movements": "Ҳаракатҳои охирин",
|
||||
"quick_actions": "Амалҳои тез"
|
||||
},
|
||||
"parts": {
|
||||
"title": "Қисмҳо",
|
||||
"new": "Қисми нав",
|
||||
"edit": "Тағйири қисм",
|
||||
"sku": "SKU",
|
||||
"name": "Ном",
|
||||
"name_en": "Ном (англисӣ)",
|
||||
"name_tg": "Ном (тоҷикӣ)",
|
||||
"description": "Тавсиф",
|
||||
"description_en": "Тавсиф (англисӣ)",
|
||||
"description_tg": "Тавсиф (тоҷикӣ)",
|
||||
"category": "Категория",
|
||||
"unit": "Воҳид",
|
||||
"cost_price": "Нархи харид",
|
||||
"sale_price": "Нархи фурӯш",
|
||||
"quantity_on_hand": "Дар анбор",
|
||||
"reorder_level": "Сатҳи фармоиш",
|
||||
"location": "Ҷой",
|
||||
"barcode": "Штрих-код",
|
||||
"active": "Фаъол",
|
||||
"search_placeholder": "Ҷустуҷӯ аз рӯи SKU, ном ё штрих-код…",
|
||||
"no_results": "Ҳеҷ қисм мувофиқат намекунад.",
|
||||
"recent_movements": "Ҳаракатҳои охирин",
|
||||
"initial_quantity": "Шумораи аввала",
|
||||
"errors": {
|
||||
"sku_required": "SKU зарур аст.",
|
||||
"name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.",
|
||||
"sku_taken": "Ин SKU аллакай истифода шудааст."
|
||||
}
|
||||
},
|
||||
"movements": {
|
||||
"title": "Ҳаракатҳои захира",
|
||||
"new": "Сабти ҳаракат",
|
||||
"type": "Намуд",
|
||||
"type_in": "Воридот (қабул)",
|
||||
"type_out": "Содирот (фурӯш / истеъмол)",
|
||||
"type_adjust": "Танзим (миқдор гузоштан)",
|
||||
"part": "Қисм",
|
||||
"quantity": "Миқдор",
|
||||
"unit_price": "Нархи воҳид",
|
||||
"supplier": "Таъминкунанда",
|
||||
"reference": "Рамзи ҳуҷҷат",
|
||||
"notes": "Эзоҳ",
|
||||
"created_at": "Сана",
|
||||
"no_movements": "Ҳоло ҳаракате сабт нашудааст.",
|
||||
"errors": {
|
||||
"part_required": "Қисмро интихоб кунед.",
|
||||
"quantity_required": "Миқдор бояд бутун ва мусбат бошад.",
|
||||
"not_enough_stock": "Дар анбор миқдори кофӣ нест."
|
||||
}
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Таъминкунандагон",
|
||||
"name": "Ном",
|
||||
"phone": "Телефон",
|
||||
"address": "Суроға",
|
||||
"notes": "Эзоҳ",
|
||||
"add": "Илова кардани таъминкунанда",
|
||||
"no_suppliers": "Ҳоло таъминкунандае нест.",
|
||||
"delete_confirm": "Ин таъминкунандаро нест мекунед?",
|
||||
"errors": {
|
||||
"name_required": "Номи таъминкунанда зарур аст."
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/lib/server/db.js
Normal file
23
src/lib/server/db.js
Normal file
@ -0,0 +1,23 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { mkdirSync, existsSync } from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// data/ lives at the repo root regardless of where the server is launched from.
|
||||
const DB_DIR = resolve(__dirname, '../../../data');
|
||||
const DB_PATH = resolve(DB_DIR, 'avtoambor.db');
|
||||
|
||||
let _db;
|
||||
|
||||
export function getDb() {
|
||||
if (_db) return _db;
|
||||
if (!existsSync(DB_DIR)) mkdirSync(DB_DIR, { recursive: true });
|
||||
_db = new Database(DB_PATH);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
return _db;
|
||||
}
|
||||
|
||||
export const DB_FILE = DB_PATH;
|
||||
90
src/lib/server/movements.js
Normal file
90
src/lib/server/movements.js
Normal file
@ -0,0 +1,90 @@
|
||||
import { getDb } from './db.js';
|
||||
|
||||
/**
|
||||
* Record a stock movement and update parts.quantity_on_hand atomically.
|
||||
* Returns the new on-hand quantity.
|
||||
*
|
||||
* type: 'in' | 'out' | 'adjust'
|
||||
* quantity: positive integer
|
||||
* - 'in': adds to on-hand
|
||||
* - 'out': subtracts from on-hand (stored as negative)
|
||||
* - 'adjust': sets on-hand to exactly this number (delta stored)
|
||||
*/
|
||||
export function recordMovement(input) {
|
||||
const db = getDb();
|
||||
const partId = Number(input.part_id);
|
||||
const type = input.movement_type;
|
||||
const qty = Math.abs(Number(input.quantity || 0));
|
||||
const unitPrice = toDirams(input.unit_price);
|
||||
const supplierId = input.supplier_id ? Number(input.supplier_id) : null;
|
||||
const reference = input.reference?.trim() || null;
|
||||
const notes = input.notes?.trim() || null;
|
||||
|
||||
if (!partId) throw new Error('part_id required');
|
||||
if (!['in','out','adjust'].includes(type)) throw new Error('invalid movement_type');
|
||||
if (!Number.isInteger(qty) || qty < 0) throw new Error('quantity must be a non-negative integer');
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const part = db.prepare(`SELECT id, quantity_on_hand FROM parts WHERE id = ?`).get(partId);
|
||||
if (!part) throw new Error(`part ${partId} not found`);
|
||||
|
||||
let storedQty; // what we save in stock_movements.quantity
|
||||
let newOnHand;
|
||||
if (type === 'in') {
|
||||
storedQty = qty;
|
||||
newOnHand = part.quantity_on_hand + qty;
|
||||
} else if (type === 'out') {
|
||||
if (qty > part.quantity_on_hand) {
|
||||
throw new Error(`not enough stock (have ${part.quantity_on_hand}, need ${qty})`);
|
||||
}
|
||||
storedQty = -qty;
|
||||
newOnHand = part.quantity_on_hand - qty;
|
||||
} else { // adjust → qty is the new total
|
||||
storedQty = qty - part.quantity_on_hand;
|
||||
newOnHand = qty;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO stock_movements
|
||||
(part_id, movement_type, quantity, unit_price, supplier_id, reference, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(partId, type, storedQty, unitPrice, supplierId, reference, notes);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE parts SET quantity_on_hand = ?, updated_at = datetime('now') WHERE id = ?
|
||||
`).run(newOnHand, partId);
|
||||
|
||||
return newOnHand;
|
||||
});
|
||||
|
||||
return tx();
|
||||
}
|
||||
|
||||
export function recentMovementsForPart(partId, limit = 20) {
|
||||
return getDb().prepare(`
|
||||
SELECT m.*, s.name AS supplier_name
|
||||
FROM stock_movements m
|
||||
LEFT JOIN suppliers s ON s.id = m.supplier_id
|
||||
WHERE m.part_id = ?
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT ?
|
||||
`).all(partId, limit);
|
||||
}
|
||||
|
||||
export function recentMovements(limit = 25) {
|
||||
return getDb().prepare(`
|
||||
SELECT m.*, p.sku, p.name_en, p.name_tg, s.name AS supplier_name
|
||||
FROM stock_movements m
|
||||
JOIN parts p ON p.id = m.part_id
|
||||
LEFT JOIN suppliers s ON s.id = m.supplier_id
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
function toDirams(value) {
|
||||
if (value === '' || value == null) return null;
|
||||
const num = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.round(num * 100);
|
||||
}
|
||||
139
src/lib/server/parts.js
Normal file
139
src/lib/server/parts.js
Normal file
@ -0,0 +1,139 @@
|
||||
import { getDb } from './db.js';
|
||||
|
||||
// Columns the user can sort the parts list by. Anything else is ignored.
|
||||
const SORTABLE = new Set([
|
||||
'sku', 'name_en', 'name_tg', 'quantity_on_hand',
|
||||
'sale_price', 'cost_price', 'reorder_level', 'updated_at'
|
||||
]);
|
||||
|
||||
export function listParts({ q = '', sort = 'sku', dir = 'asc' } = {}) {
|
||||
const db = getDb();
|
||||
const col = SORTABLE.has(sort) ? sort : 'sku';
|
||||
const order = dir === 'desc' ? 'DESC' : 'ASC';
|
||||
|
||||
const where = [];
|
||||
const params = {};
|
||||
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)`);
|
||||
params.q = `%${q.trim()}%`;
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const sql = `
|
||||
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg
|
||||
FROM parts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
${whereSql}
|
||||
ORDER BY ${col} ${order}
|
||||
`;
|
||||
return db.prepare(sql).all(params);
|
||||
}
|
||||
|
||||
export function getPart(id) {
|
||||
return getDb().prepare(`
|
||||
SELECT p.*, c.name_en AS category_name_en, c.name_tg AS category_name_tg
|
||||
FROM parts p
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE p.id = ?
|
||||
`).get(id);
|
||||
}
|
||||
|
||||
export function getPartBySku(sku) {
|
||||
return getDb().prepare(`SELECT * FROM parts WHERE sku = ?`).get(sku);
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return getDb()
|
||||
.prepare(`SELECT * FROM categories ORDER BY sort_order, name_en`)
|
||||
.all();
|
||||
}
|
||||
|
||||
export function createPart(input) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO parts
|
||||
(sku, name_en, name_tg, description_en, description_tg,
|
||||
category_id, unit, cost_price, sale_price,
|
||||
quantity_on_hand, reorder_level, location, barcode, active)
|
||||
VALUES
|
||||
(@sku, @name_en, @name_tg, @description_en, @description_tg,
|
||||
@category_id, @unit, @cost_price, @sale_price,
|
||||
@quantity_on_hand, @reorder_level, @location, @barcode, @active)
|
||||
`);
|
||||
const result = stmt.run(normalizePart(input));
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function updatePart(id, input) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
UPDATE parts SET
|
||||
sku = @sku,
|
||||
name_en = @name_en,
|
||||
name_tg = @name_tg,
|
||||
description_en = @description_en,
|
||||
description_tg = @description_tg,
|
||||
category_id = @category_id,
|
||||
unit = @unit,
|
||||
cost_price = @cost_price,
|
||||
sale_price = @sale_price,
|
||||
reorder_level = @reorder_level,
|
||||
location = @location,
|
||||
barcode = @barcode,
|
||||
active = @active,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = @id
|
||||
`);
|
||||
// Note: quantity_on_hand is intentionally NOT editable here — it changes
|
||||
// only through stock_movements.
|
||||
stmt.run({ ...normalizePart(input), id });
|
||||
}
|
||||
|
||||
function normalizePart(p) {
|
||||
return {
|
||||
sku: (p.sku || '').trim(),
|
||||
name_en: (p.name_en || '').trim(),
|
||||
name_tg: (p.name_tg || '').trim(),
|
||||
description_en: p.description_en?.trim() || null,
|
||||
description_tg: p.description_tg?.trim() || null,
|
||||
category_id: p.category_id ? Number(p.category_id) : null,
|
||||
unit: (p.unit || 'pcs').trim(),
|
||||
cost_price: toDirams(p.cost_price),
|
||||
sale_price: toDirams(p.sale_price),
|
||||
quantity_on_hand: Number.isFinite(Number(p.quantity_on_hand)) ? Number(p.quantity_on_hand) : 0,
|
||||
reorder_level: Number.isFinite(Number(p.reorder_level)) ? Number(p.reorder_level) : 0,
|
||||
location: p.location?.trim() || null,
|
||||
barcode: p.barcode?.trim() || null,
|
||||
active: p.active === false || p.active === '0' || p.active === 'false' ? 0 : 1
|
||||
};
|
||||
}
|
||||
|
||||
// Accepts somoni-as-string (e.g. "12.50") and returns INTEGER dirams.
|
||||
function toDirams(value) {
|
||||
if (value === '' || value == null) return 0;
|
||||
const num = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
|
||||
if (!Number.isFinite(num)) return 0;
|
||||
return Math.round(num * 100);
|
||||
}
|
||||
|
||||
export function dashboardStats() {
|
||||
const db = getDb();
|
||||
const total = db.prepare(`SELECT COUNT(*) AS n FROM parts WHERE active = 1`).get().n;
|
||||
const lowStock = db.prepare(`
|
||||
SELECT COUNT(*) AS n FROM parts
|
||||
WHERE active = 1 AND quantity_on_hand <= reorder_level
|
||||
`).get().n;
|
||||
const value = db.prepare(`
|
||||
SELECT COALESCE(SUM(quantity_on_hand * cost_price), 0) AS v FROM parts WHERE active = 1
|
||||
`).get().v;
|
||||
return { total, lowStock, inventoryValueDirams: value };
|
||||
}
|
||||
|
||||
export function lowStockParts(limit = 10) {
|
||||
return getDb().prepare(`
|
||||
SELECT * FROM parts
|
||||
WHERE active = 1 AND quantity_on_hand <= reorder_level
|
||||
ORDER BY (quantity_on_hand - reorder_level) ASC, sku ASC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
56
src/lib/server/schema.sql
Normal file
56
src/lib/server/schema.sql
Normal file
@ -0,0 +1,56 @@
|
||||
-- AvtoAmbor schema. Money is stored as INTEGER dirams (1 TJS = 100 dirams).
|
||||
-- Translated fields use _en / _tg suffixes.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name_en TEXT NOT NULL,
|
||||
name_tg TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sku TEXT NOT NULL UNIQUE,
|
||||
name_en TEXT NOT NULL,
|
||||
name_tg TEXT NOT NULL,
|
||||
description_en TEXT,
|
||||
description_tg TEXT,
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'pcs',
|
||||
cost_price INTEGER NOT NULL DEFAULT 0, -- dirams
|
||||
sale_price INTEGER NOT NULL DEFAULT 0, -- dirams
|
||||
quantity_on_hand INTEGER NOT NULL DEFAULT 0,
|
||||
reorder_level INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
barcode TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stock_movements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||
movement_type TEXT NOT NULL CHECK(movement_type IN ('in','out','adjust')),
|
||||
quantity INTEGER NOT NULL, -- positive for in/adjust-up, negative for out/adjust-down
|
||||
unit_price INTEGER, -- dirams; nullable for adjustments
|
||||
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
|
||||
reference TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_parts_sku ON parts(sku);
|
||||
CREATE INDEX IF NOT EXISTS idx_parts_barcode ON parts(barcode);
|
||||
CREATE INDEX IF NOT EXISTS idx_parts_category ON parts(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_movements_part ON stock_movements(part_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_movements_created ON stock_movements(created_at);
|
||||
190
src/lib/server/seed.sql
Normal file
190
src/lib/server/seed.sql
Normal file
@ -0,0 +1,190 @@
|
||||
-- Seed data for AvtoAmbor. Prices are in dirams (1 TJS = 100 dirams).
|
||||
-- Names are biased toward Lada / Daewoo Nexia / Opel / Toyota, which are
|
||||
-- common in Tajikistan.
|
||||
|
||||
INSERT INTO categories (id, name_en, name_tg, sort_order) VALUES
|
||||
(1, 'Filters', 'Филтрҳо', 10),
|
||||
(2, 'Brakes', 'Тормоз', 20),
|
||||
(3, 'Engine', 'Муҳаррик', 30),
|
||||
(4, 'Electrical', 'Барқӣ', 40),
|
||||
(5, 'Fluids', 'Моеъҳо', 50),
|
||||
(6, 'Belts & Hoses', 'Тасма ва шланг', 60);
|
||||
|
||||
INSERT INTO suppliers (name, phone, address, notes) VALUES
|
||||
('Avtomir Dushanbe', '+992 37 221 33 44', 'Dushanbe, Rudaki ave. 112', 'General auto parts wholesale.'),
|
||||
('Nexia Parts TJ', '+992 92 700 12 34', 'Dushanbe, Korvon market row 8', 'Daewoo / Chevrolet specialist.'),
|
||||
('Vostok Auto', '+992 93 555 77 11', 'Khujand, Lenin st. 45', 'VAZ / Lada / Niva parts.'),
|
||||
('Korea Motors TJ', '+992 90 411 22 33', 'Dushanbe, Sino district', 'Korean and Japanese imports.');
|
||||
|
||||
-- Parts ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO parts
|
||||
(sku, name_en, name_tg, description_en, description_tg,
|
||||
category_id, unit, cost_price, sale_price,
|
||||
quantity_on_hand, reorder_level, location, barcode)
|
||||
VALUES
|
||||
-- Filters
|
||||
('FLT-LDA-OIL-01', 'Oil filter Lada 2107', 'Филтри равған Lada 2107',
|
||||
'Standard oil filter for VAZ 2101–2107 engines.',
|
||||
'Филтри равғани муқаррарӣ барои ВАЗ 2101–2107.',
|
||||
1, 'pcs', 2500, 4000, 24, 6, 'A1-01', '4607000000017'),
|
||||
|
||||
('FLT-DWO-OIL-01', 'Oil filter Daewoo Nexia', 'Филтри равған Daewoo Nexia',
|
||||
'Oil filter for Daewoo Nexia 1.5 SOHC/DOHC.',
|
||||
'Филтри равған барои Daewoo Nexia 1.5 SOHC/DOHC.',
|
||||
1, 'pcs', 2800, 4500, 18, 6, 'A1-02', '4607000000024'),
|
||||
|
||||
('FLT-LDA-AIR-01', 'Air filter Lada Niva', 'Филтри ҳаво Lada Niva',
|
||||
'Round-style air filter for Lada Niva 1.7i.',
|
||||
'Филтри ҳавои гирд барои Lada Niva 1.7i.',
|
||||
1, 'pcs', 3200, 5000, 12, 4, 'A1-03', '4607000000031'),
|
||||
|
||||
('FLT-OPL-AIR-01', 'Air filter Opel Astra H', 'Филтри ҳаво Opel Astra H',
|
||||
'Panel air filter for Opel Astra H 1.6 / 1.8.',
|
||||
'Филтри ҳавои панелӣ барои Opel Astra H 1.6 / 1.8.',
|
||||
1, 'pcs', 4500, 7000, 9, 3, 'A1-04', '4607000000048'),
|
||||
|
||||
('FLT-TYT-FUL-01', 'Fuel filter Toyota Camry', 'Филтри сӯзишворӣ Toyota Camry',
|
||||
'In-line fuel filter for Toyota Camry V30/V40.',
|
||||
'Филтри сӯзишвории трубачагӣ барои Toyota Camry V30/V40.',
|
||||
1, 'pcs', 6000, 9500, 7, 3, 'A1-05', '4607000000055'),
|
||||
|
||||
('FLT-DWO-CAB-01', 'Cabin filter Daewoo Nexia', 'Филтри салон Daewoo Nexia',
|
||||
'Cabin / pollen filter for Daewoo Nexia.',
|
||||
'Филтри салон / гардолуд барои Daewoo Nexia.',
|
||||
1, 'pcs', 3500, 5500, 14, 4, 'A1-06', '4607000000062'),
|
||||
|
||||
-- Brakes
|
||||
('BRK-LDA-PAD-F', 'Front brake pads Lada 2110', 'Колодкаҳои пеши тормоз Lada 2110',
|
||||
'Front brake pad set for Lada 2110/2111/2112.',
|
||||
'Маҷмӯи колодкаҳои пеши тормоз барои Lada 2110/2111/2112.',
|
||||
2, 'set', 11000, 17000, 10, 3, 'B2-01', '4607000000079'),
|
||||
|
||||
('BRK-LDA-SHO-R', 'Rear brake shoes Lada Niva', 'Колодкаҳои қафои тормоз Lada Niva',
|
||||
'Rear drum brake shoe set for Lada Niva.',
|
||||
'Маҷмӯи колодкаҳои қафои барабаниро Lada Niva.',
|
||||
2, 'set', 9500, 15000, 8, 3, 'B2-02', '4607000000086'),
|
||||
|
||||
('BRK-DWO-DSC-F', 'Front brake disc Daewoo Nexia', 'Диски тормози пеш Daewoo Nexia',
|
||||
'Ventilated front brake disc, Daewoo Nexia.',
|
||||
'Диски тормози пеши вентилятсияшаванда, Daewoo Nexia.',
|
||||
2, 'pcs', 18000, 28000, 6, 2, 'B2-03', '4607000000093'),
|
||||
|
||||
('BRK-OPL-PAD-F', 'Front brake pads Opel Vectra B', 'Колодкаҳои пеши тормоз Opel Vectra B',
|
||||
'Front brake pad set, Opel Vectra B 1.6 / 1.8.',
|
||||
'Маҷмӯи колодкаҳои пеши тормоз, Opel Vectra B 1.6 / 1.8.',
|
||||
2, 'set', 16000, 24000, 5, 2, 'B2-04', '4607000000109'),
|
||||
|
||||
('BRK-TYT-PAD-F', 'Front brake pads Toyota Corolla','Колодкаҳои пеши тормоз Toyota Corolla',
|
||||
'Front brake pad set, Toyota Corolla E120/E150.',
|
||||
'Маҷмӯи колодкаҳои пеши тормоз, Toyota Corolla E120/E150.',
|
||||
2, 'set', 19000, 29500, 4, 2, 'B2-05', '4607000000116'),
|
||||
|
||||
-- Engine
|
||||
('ENG-SPK-NGK-01', 'Spark plug NGK BPR6E', 'Шамъи оташфурӯзӣ NGK BPR6E',
|
||||
'NGK BPR6E spark plug — common for VAZ.',
|
||||
'Шамъи оташфурӯзии NGK BPR6E — барои ВАЗ.',
|
||||
3, 'pcs', 1800, 3000, 60, 12, 'C3-01', '4607000000123'),
|
||||
|
||||
('ENG-SPK-DEN-01', 'Spark plug Denso K20TT', 'Шамъи оташфурӯзӣ Denso K20TT',
|
||||
'Denso K20TT twin-tip plug, Toyota / Daewoo.',
|
||||
'Шамъи Denso K20TT, Toyota / Daewoo.',
|
||||
3, 'pcs', 3200, 5000, 36, 8, 'C3-02', '4607000000130'),
|
||||
|
||||
('ENG-LDA-PR-01', 'Piston ring set Lada 2106 STD', 'Маҷмӯи ҳалқаҳои поршен Lada 2106 STD',
|
||||
'Standard-bore piston ring set for VAZ 2106.',
|
||||
'Маҷмӯи ҳалқаҳои поршени андозаи стандартӣ барои ВАЗ 2106.',
|
||||
3, 'set', 22000, 33000, 3, 1, 'C3-03', '4607000000147'),
|
||||
|
||||
('ENG-LDA-VCG-01', 'Valve cover gasket Lada 2110', 'Прокладкаи сарпӯши клапанҳо Lada 2110',
|
||||
'Valve cover gasket for Lada 2110 16V.',
|
||||
'Прокладкаи сарпӯши клапанҳо барои Lada 2110 16V.',
|
||||
3, 'pcs', 3000, 4800, 11, 3, 'C3-04', '4607000000154'),
|
||||
|
||||
('ENG-DWO-MNT-01', 'Engine mount Daewoo Nexia', 'Подушкаи муҳаррик Daewoo Nexia',
|
||||
'Right-side engine mount for Daewoo Nexia.',
|
||||
'Подушкаи рости муҳаррик барои Daewoo Nexia.',
|
||||
3, 'pcs', 14000, 22000, 4, 2, 'C3-05', '4607000000161'),
|
||||
|
||||
('ENG-OPL-THM-01', 'Thermostat Opel Astra H', 'Термостат Opel Astra H',
|
||||
'Thermostat with housing, Opel Astra H 1.6.',
|
||||
'Термостат бо корпус, Opel Astra H 1.6.',
|
||||
3, 'pcs', 17000, 26000, 3, 1, 'C3-06', '4607000000178'),
|
||||
|
||||
('ENG-DWO-WPM-01', 'Water pump Daewoo Nexia', 'Насоси об Daewoo Nexia',
|
||||
'Coolant water pump, Daewoo Nexia 1.5.',
|
||||
'Насоси хунуккунӣ, Daewoo Nexia 1.5.',
|
||||
3, 'pcs', 25000, 38000, 3, 1, 'C3-07', '4607000000185'),
|
||||
|
||||
-- Electrical
|
||||
('ELC-LDA-STR-01', 'Starter motor Lada 2107', 'Стартери Lada 2107',
|
||||
'Reduction-gear starter for Lada 2107 carb / inj.',
|
||||
'Стартери редукторӣ барои Lada 2107.',
|
||||
4, 'pcs', 65000, 95000, 2, 1, 'D4-01', '4607000000192'),
|
||||
|
||||
('ELC-DWO-ALT-01', 'Alternator 14V 80A Daewoo Nexia','Генератори 14V 80A Daewoo Nexia',
|
||||
'14V 80A alternator, Daewoo Nexia 1.5.',
|
||||
'Генератори 14V 80A, Daewoo Nexia 1.5.',
|
||||
4, 'pcs', 95000,140000, 2, 1, 'D4-02', '4607000000208'),
|
||||
|
||||
('ELC-BAT-60AH-01','Car battery 60Ah 12V', 'Батареяи мошин 60Ач 12В',
|
||||
'Maintenance-free 60Ah 12V battery, 500A CCA.',
|
||||
'Батареяи бе нигоҳдории 60Ач 12В, 500А CCA.',
|
||||
4, 'pcs', 55000, 78000, 6, 2, 'D4-03', '4607000000215'),
|
||||
|
||||
('ELC-H4-12V-01', 'Headlamp bulb H4 12V 60/55W', 'Лампаи фара H4 12V 60/55Вт',
|
||||
'Halogen H4 headlamp bulb 12V 60/55W.',
|
||||
'Лампаи галогении H4 барои фара 12V 60/55Вт.',
|
||||
4, 'pcs', 1200, 2200, 40, 10, 'D4-04', '4607000000222'),
|
||||
|
||||
('ELC-TYT-COL-01', 'Ignition coil Toyota Corolla', 'Ғалтаки оташфурӯзӣ Toyota Corolla',
|
||||
'Pencil-type ignition coil, Toyota Corolla 1.6 VVT-i.',
|
||||
'Ғалтаки оташфурӯзии қаламшакл, Toyota Corolla 1.6 VVT-i.',
|
||||
4, 'pcs', 28000, 42000, 4, 2, 'D4-05', '4607000000239'),
|
||||
|
||||
-- Fluids
|
||||
('FLD-OIL-5W40-4L','Engine oil 5W-40 synthetic 4L', 'Равғани муҳаррик 5W-40 синтетикӣ 4Л',
|
||||
'Fully synthetic 5W-40 motor oil, 4L jug.',
|
||||
'Равғани муҳаррики пурра синтетикии 5W-40, 4 литр.',
|
||||
5, 'btl', 14000, 21000, 15, 4, 'E5-01', '4607000000246'),
|
||||
|
||||
('FLD-OIL-10W40-4L','Engine oil 10W-40 semi-syn 4L', 'Равғани муҳаррик 10W-40 нимсинтетикӣ 4Л',
|
||||
'Semi-synthetic 10W-40 motor oil, 4L jug.',
|
||||
'Равғани муҳаррики нимсинтетикии 10W-40, 4 литр.',
|
||||
5, 'btl', 9500, 15000, 22, 6, 'E5-02', '4607000000253'),
|
||||
|
||||
('FLD-BRK-DOT4-1L','Brake fluid DOT-4 1L', 'Моеъи тормоз DOT-4 1Л',
|
||||
'DOT-4 brake fluid, 1L bottle.',
|
||||
'Моеъи тормози DOT-4, шишаи 1 литр.',
|
||||
5, 'btl', 2200, 3800, 20, 6, 'E5-03', '4607000000260'),
|
||||
|
||||
('FLD-AFR-G11-5L', 'Antifreeze G11 green 5L', 'Антифриз G11 сабз 5Л',
|
||||
'G11 (green) antifreeze concentrate, 5L.',
|
||||
'Антифризи G11 (сабз), консентрат, 5 литр.',
|
||||
5, 'btl', 6800, 10500, 10, 3, 'E5-04', '4607000000277'),
|
||||
|
||||
-- Belts & Hoses
|
||||
('BLT-DWO-TIM-01', 'Timing belt Daewoo Nexia 8V', 'Тасмаи газораспределение Daewoo Nexia 8V',
|
||||
'Timing belt for Daewoo Nexia 1.5 SOHC (8V).',
|
||||
'Тасмаи газораспределение барои Daewoo Nexia 1.5 SOHC (8V).',
|
||||
6, 'pcs', 5500, 9000, 8, 3, 'F6-01', '4607000000284'),
|
||||
|
||||
('BLT-LDA-VBL-01', 'V-belt Lada 2107 alternator', 'Тасмаи V Lada 2107 (генератор)',
|
||||
'V-belt for alternator, Lada 2107 carb engine.',
|
||||
'Тасмаи V барои генератор, муҳаррики карбюратории Lada 2107.',
|
||||
6, 'pcs', 1800, 3000, 20, 5, 'F6-02', '4607000000291'),
|
||||
|
||||
('HOS-LDA-RUP-01', 'Radiator upper hose Lada 2107', 'Шланги болоии радиатор Lada 2107',
|
||||
'Upper radiator hose, Lada 2107.',
|
||||
'Шланги болоии радиатор, Lada 2107.',
|
||||
6, 'pcs', 2400, 3900, 12, 4, 'F6-03', '4607000000307'),
|
||||
|
||||
('HOS-DWO-RLW-01', 'Radiator lower hose Daewoo Nexia','Шланги поёнии радиатор Daewoo Nexia',
|
||||
'Lower radiator hose, Daewoo Nexia.',
|
||||
'Шланги поёнии радиатор, Daewoo Nexia.',
|
||||
6, 'pcs', 3000, 4800, 9, 3, 'F6-04', '4607000000314');
|
||||
|
||||
-- A couple of opening "in" stock movements so the dashboard isn't empty.
|
||||
INSERT INTO stock_movements (part_id, movement_type, quantity, unit_price, supplier_id, reference, notes)
|
||||
SELECT id, 'in', quantity_on_hand, cost_price, 1, 'OPENING', 'Initial seeded stock'
|
||||
FROM parts WHERE quantity_on_hand > 0;
|
||||
27
src/lib/server/suppliers.js
Normal file
27
src/lib/server/suppliers.js
Normal file
@ -0,0 +1,27 @@
|
||||
import { getDb } from './db.js';
|
||||
|
||||
export function listSuppliers() {
|
||||
return getDb().prepare(`SELECT * FROM suppliers ORDER BY name`).all();
|
||||
}
|
||||
|
||||
export function getSupplier(id) {
|
||||
return getDb().prepare(`SELECT * FROM suppliers WHERE id = ?`).get(id);
|
||||
}
|
||||
|
||||
export function createSupplier(input) {
|
||||
const stmt = getDb().prepare(`
|
||||
INSERT INTO suppliers (name, phone, address, notes)
|
||||
VALUES (@name, @phone, @address, @notes)
|
||||
`);
|
||||
const result = stmt.run({
|
||||
name: (input.name || '').trim(),
|
||||
phone: input.phone?.trim() || null,
|
||||
address: input.address?.trim() || null,
|
||||
notes: input.notes?.trim() || null
|
||||
});
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
export function deleteSupplier(id) {
|
||||
getDb().prepare(`DELETE FROM suppliers WHERE id = ?`).run(id);
|
||||
}
|
||||
108
src/routes/+layout.svelte
Normal file
108
src/routes/+layout.svelte
Normal file
@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import Header from '$lib/components/Header.svelte';
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
|
||||
<main class="container">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; }
|
||||
:global(html, body) { margin: 0; padding: 0; }
|
||||
:global(body) {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
color: #1d2330;
|
||||
background: #fbf7f1; /* warm cream — a slightly Central-Asian palette */
|
||||
}
|
||||
:global(a) { color: #006a4e; }
|
||||
:global(a:hover) { color: #00553e; }
|
||||
:global(h1) { font-size: 1.6rem; margin: 0 0 1rem; }
|
||||
:global(h2) { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
|
||||
|
||||
:global(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
:global(th, td) {
|
||||
text-align: left;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-bottom: 1px solid #e5e8ee;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
:global(th) {
|
||||
background: #eef1f6;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:global(tr:hover td) { background: #fafbfd; }
|
||||
:global(td.num, th.num) { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
:global(form.stack) { display: grid; gap: 0.9rem; max-width: 640px; }
|
||||
:global(form.stack label) { display: grid; gap: 0.25rem; font-size: 0.9rem; }
|
||||
:global(form.stack .row) { display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem; }
|
||||
:global(input, select, textarea) {
|
||||
font: inherit;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid #c8cfdc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
}
|
||||
:global(textarea) { min-height: 4.5rem; }
|
||||
:global(button) {
|
||||
font: inherit;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
background: #006a4e;
|
||||
color: #fff;
|
||||
}
|
||||
:global(button:hover) { background: #00553e; }
|
||||
:global(button.secondary) {
|
||||
background: #fff;
|
||||
color: #1d2330;
|
||||
border-color: #c8cfdc;
|
||||
}
|
||||
:global(button.secondary:hover) { background: #f0f2f6; }
|
||||
:global(button.danger) {
|
||||
background: #c8102e;
|
||||
}
|
||||
:global(button.danger:hover) { background: #a30d24; }
|
||||
:global(button:disabled) { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
:global(.card) {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 1rem 1.25rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
:global(.error) {
|
||||
background: #fdecea;
|
||||
border: 1px solid #f5c2c0;
|
||||
color: #8a1f1b;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
:global(.muted) { color: #6b7388; }
|
||||
:global(.pill) {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #eef1f6;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
:global(.pill.low) { background: #fde6e4; color: #8a1f1b; }
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.25rem 3rem;
|
||||
}
|
||||
</style>
|
||||
10
src/routes/+page.server.js
Normal file
10
src/routes/+page.server.js
Normal file
@ -0,0 +1,10 @@
|
||||
import { dashboardStats, lowStockParts } from '$lib/server/parts.js';
|
||||
import { recentMovements } from '$lib/server/movements.js';
|
||||
|
||||
export function load() {
|
||||
return {
|
||||
stats: dashboardStats(),
|
||||
lowStock: lowStockParts(10),
|
||||
movements: recentMovements(10)
|
||||
};
|
||||
}
|
||||
109
src/routes/+page.svelte
Normal file
109
src/routes/+page.svelte
Normal file
@ -0,0 +1,109 @@
|
||||
<script>
|
||||
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
$: lang = $locale;
|
||||
$: ({ stats, lowStock, movements } = data);
|
||||
</script>
|
||||
|
||||
<h1>{$t('dashboard.title')}</h1>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('dashboard.total_skus')}</div>
|
||||
<div class="value">{stats.total}</div>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('dashboard.low_stock')}</div>
|
||||
<div class="value" class:warn={stats.lowStock > 0}>{stats.lowStock}</div>
|
||||
</div>
|
||||
<div class="card stat">
|
||||
<div class="label">{$t('dashboard.inventory_value')}</div>
|
||||
<div class="value">
|
||||
{formatMoney(stats.inventoryValueDirams, lang)}
|
||||
<span class="cur">{$t('common.currency_short')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card quick">
|
||||
<strong>{$t('dashboard.quick_actions')}</strong>
|
||||
<a href="/parts/new">{$t('nav.new_part')}</a>
|
||||
<a href="/movements/new">{$t('nav.new_movement')}</a>
|
||||
<a href="/parts">{$t('nav.parts')}</a>
|
||||
</div>
|
||||
|
||||
<h2>{$t('dashboard.low_stock_list')}</h2>
|
||||
{#if lowStock.length === 0}
|
||||
<p class="muted">{$t('common.none')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('parts.sku')}</th>
|
||||
<th>{$t('parts.name')}</th>
|
||||
<th class="num">{$t('parts.quantity_on_hand')}</th>
|
||||
<th class="num">{$t('parts.reorder_level')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each lowStock as p}
|
||||
<tr>
|
||||
<td><a href="/parts/{p.id}">{p.sku}</a></td>
|
||||
<td>{localized(p, 'name', lang)}</td>
|
||||
<td class="num"><span class="pill low">{p.quantity_on_hand}</span></td>
|
||||
<td class="num">{p.reorder_level}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<h2>{$t('dashboard.recent_movements')}</h2>
|
||||
{#if movements.length === 0}
|
||||
<p class="muted">{$t('movements.no_movements')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('movements.created_at')}</th>
|
||||
<th>{$t('movements.type')}</th>
|
||||
<th>{$t('parts.sku')}</th>
|
||||
<th>{$t('parts.name')}</th>
|
||||
<th class="num">{$t('movements.quantity')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each movements as m}
|
||||
<tr>
|
||||
<td>{m.created_at}</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>{localized(m, 'name', lang)}</td>
|
||||
<td class="num">{m.quantity}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat .label { color: #6b7388; font-size: 0.85rem; }
|
||||
.stat .value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat .value.warn { color: #b8443f; }
|
||||
.stat .cur { font-size: 0.85rem; color: #6b7388; margin-left: 0.25rem; }
|
||||
|
||||
.quick { display: flex; align-items: center; gap: 1rem; margin: 1rem 0; }
|
||||
.quick strong { margin-right: auto; }
|
||||
</style>
|
||||
39
src/routes/movements/new/+page.server.js
Normal file
39
src/routes/movements/new/+page.server.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { listParts } from '$lib/server/parts.js';
|
||||
import { listSuppliers } from '$lib/server/suppliers.js';
|
||||
import { recordMovement } from '$lib/server/movements.js';
|
||||
|
||||
export function load({ url }) {
|
||||
return {
|
||||
parts: listParts(),
|
||||
suppliers: listSuppliers(),
|
||||
presetPartId: url.searchParams.get('part_id') || ''
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const data = Object.fromEntries(form);
|
||||
|
||||
const errors = {};
|
||||
if (!data.part_id) errors.part_id = 'movements.errors.part_required';
|
||||
const qty = Number(data.quantity);
|
||||
if (!Number.isInteger(qty) || qty < 0) errors.quantity = 'movements.errors.quantity_required';
|
||||
// 'in' or 'adjust' may legitimately use 0 (e.g. adjust to zero), so we
|
||||
// only block negative or non-integer; 'out' requires > 0.
|
||||
if (data.movement_type === 'out' && qty <= 0) errors.quantity = 'movements.errors.quantity_required';
|
||||
if (Object.keys(errors).length) return fail(400, { errors, values: data });
|
||||
|
||||
try {
|
||||
recordMovement(data);
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('not enough stock')) {
|
||||
return fail(400, { errors: { quantity: 'movements.errors.not_enough_stock' }, values: data });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
throw redirect(303, `/parts/${data.part_id}`);
|
||||
}
|
||||
};
|
||||
168
src/routes/movements/new/+page.svelte
Normal file
168
src/routes/movements/new/+page.svelte
Normal file
@ -0,0 +1,168 @@
|
||||
<script>
|
||||
import { locale, t, localized } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: lang = $locale;
|
||||
$: ({ parts, suppliers, presetPartId } = data);
|
||||
|
||||
$: errors = form?.errors ?? {};
|
||||
$: values = form?.values ?? {};
|
||||
|
||||
// Reference data/form directly here — the reactive `$: values = ...` above
|
||||
// hasn't run yet at component init.
|
||||
let movementType = form?.values?.movement_type ?? 'in';
|
||||
let partId = String(form?.values?.part_id ?? data?.presetPartId ?? '');
|
||||
let partSearch = '';
|
||||
|
||||
$: filteredParts = (() => {
|
||||
const q = partSearch.trim().toLowerCase();
|
||||
if (!q) return parts;
|
||||
return parts.filter((p) =>
|
||||
(p.sku || '').toLowerCase().includes(q) ||
|
||||
(p.name_en || '').toLowerCase().includes(q) ||
|
||||
(p.name_tg || '').toLowerCase().includes(q) ||
|
||||
(p.barcode || '').toLowerCase().includes(q)
|
||||
);
|
||||
})();
|
||||
|
||||
// If the user filters away the currently selected part, still keep it
|
||||
// visible in the dropdown so they don't lose context.
|
||||
$: selectedPart = parts?.find((p) => String(p.id) === String(partId));
|
||||
$: visibleParts =
|
||||
selectedPart && !filteredParts.some((p) => p.id === selectedPart.id)
|
||||
? [selectedPart, ...filteredParts]
|
||||
: filteredParts;
|
||||
|
||||
// Unit price: auto-filled from the chosen part — cost for 'in', sale for
|
||||
// 'out'. We track the last auto-filled value; if the user has typed
|
||||
// something different, we leave their input alone on subsequent
|
||||
// part/type changes.
|
||||
let unitPrice = form?.values?.unit_price ?? '';
|
||||
let lastAutoUnitPrice = '';
|
||||
|
||||
function priceFor(part, type) {
|
||||
if (!part || type === 'adjust') return '';
|
||||
const dirams = type === 'in' ? part.cost_price : part.sale_price;
|
||||
if (!dirams || dirams <= 0) return '';
|
||||
return (dirams / 100).toFixed(2);
|
||||
}
|
||||
|
||||
$: {
|
||||
const expected = priceFor(selectedPart, movementType);
|
||||
if (expected && (unitPrice === '' || unitPrice === lastAutoUnitPrice)) {
|
||||
unitPrice = expected;
|
||||
lastAutoUnitPrice = expected;
|
||||
}
|
||||
}
|
||||
|
||||
// Quantity: for 'adjust' we pre-fill with the part's current on-hand so
|
||||
// the user can edit to the new total. Same don't-clobber-manual-edits
|
||||
// rule as unit price.
|
||||
let quantity = form?.values?.quantity ?? '';
|
||||
let lastAutoQuantity = '';
|
||||
|
||||
$: {
|
||||
if (movementType === 'adjust' && selectedPart) {
|
||||
const expected = String(selectedPart.quantity_on_hand);
|
||||
if (quantity === '' || quantity === lastAutoQuantity) {
|
||||
quantity = expected;
|
||||
lastAutoQuantity = expected;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>{$t('movements.new')}</h1>
|
||||
|
||||
<form class="stack" method="POST">
|
||||
<label>
|
||||
{$t('movements.type')}
|
||||
<select name="movement_type" bind:value={movementType}>
|
||||
<option value="in">{$t('movements.type_in')}</option>
|
||||
<option value="out">{$t('movements.type_out')}</option>
|
||||
<option value="adjust">{$t('movements.type_adjust')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{$t('movements.part')} *
|
||||
<input class="part-search"
|
||||
type="search"
|
||||
bind:value={partSearch}
|
||||
placeholder={$t('parts.search_placeholder')} />
|
||||
<select name="part_id" bind:value={partId} required size={Math.min(8, Math.max(3, visibleParts.length + 1))}>
|
||||
<option value="">—</option>
|
||||
{#each visibleParts as p}
|
||||
<option value={p.id}>
|
||||
{p.sku} — {localized(p, 'name', lang)} ({$t('parts.quantity_on_hand')}: {p.quantity_on_hand})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if partSearch && filteredParts.length === 0}
|
||||
<small class="muted">{$t('parts.no_results')}</small>
|
||||
{/if}
|
||||
{#if errors.part_id}<span class="field-error">{$t(errors.part_id)}</span>{/if}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{$t('movements.quantity')} *
|
||||
<input name="quantity" type="number" min="0" step="1" required
|
||||
bind:value={quantity} />
|
||||
{#if errors.quantity}<span class="field-error">{$t(errors.quantity)}</span>{/if}
|
||||
{#if movementType === 'adjust' && selectedPart}
|
||||
<small class="muted">
|
||||
{$t('parts.quantity_on_hand')}: {selectedPart.quantity_on_hand}
|
||||
</small>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
{#if movementType !== 'adjust'}
|
||||
<label>
|
||||
{$t('movements.unit_price')} ({$t('common.currency_short')})
|
||||
<input name="unit_price" type="number" step="0.01" min="0"
|
||||
bind:value={unitPrice} />
|
||||
{#if selectedPart && unitPrice === lastAutoUnitPrice && unitPrice !== ''}
|
||||
<small class="muted">
|
||||
{movementType === 'in' ? $t('parts.cost_price') : $t('parts.sale_price')}
|
||||
</small>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if movementType === 'in'}
|
||||
<label>
|
||||
{$t('movements.supplier')}
|
||||
<select name="supplier_id">
|
||||
<option value="">—</option>
|
||||
{#each suppliers as s}
|
||||
<option value={s.id} selected={String(values.supplier_id) === String(s.id)}>
|
||||
{s.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('movements.reference')}
|
||||
<input name="reference" value={values.reference ?? ''} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('movements.notes')}
|
||||
<input name="notes" value={values.notes ?? ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">{$t('common.save')}</button>
|
||||
<a href="/parts" class="muted">{$t('common.cancel')}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.actions { display: flex; gap: 1rem; align-items: center; }
|
||||
.field-error { color: #8a1f1b; font-size: 0.8rem; }
|
||||
.part-search { margin-bottom: 0.35rem; }
|
||||
</style>
|
||||
8
src/routes/parts/+page.server.js
Normal file
8
src/routes/parts/+page.server.js
Normal file
@ -0,0 +1,8 @@
|
||||
import { listParts } from '$lib/server/parts.js';
|
||||
|
||||
export function load({ url }) {
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
const sort = url.searchParams.get('sort') ?? 'sku';
|
||||
const dir = url.searchParams.get('dir') ?? 'asc';
|
||||
return { parts: listParts({ q, sort, dir }), q, sort, dir };
|
||||
}
|
||||
126
src/routes/parts/+page.svelte
Normal file
126
src/routes/parts/+page.svelte
Normal file
@ -0,0 +1,126 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale, t, localized, formatMoney, hasTranslation } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
$: lang = $locale;
|
||||
$: ({ parts, q, sort, dir } = data);
|
||||
|
||||
let search = data.q;
|
||||
|
||||
function applySearch(e) {
|
||||
e?.preventDefault?.();
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
if (sort && sort !== 'sku') params.set('sort', sort);
|
||||
if (dir && dir !== 'asc') params.set('dir', dir);
|
||||
goto('/parts' + (params.toString() ? '?' + params.toString() : ''));
|
||||
}
|
||||
|
||||
function sortBy(col) {
|
||||
let nextDir = 'asc';
|
||||
if (sort === col && dir === 'asc') nextDir = 'desc';
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set('q', search);
|
||||
params.set('sort', col);
|
||||
params.set('dir', nextDir);
|
||||
goto('/parts?' + params.toString());
|
||||
}
|
||||
|
||||
function arrow(col) {
|
||||
if (sort !== col) return '';
|
||||
return dir === 'asc' ? '▲' : '▼';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>{$t('parts.title')}</h1>
|
||||
<a class="add-btn" href="/parts/new">+ {$t('nav.new_part')}</a>
|
||||
</div>
|
||||
|
||||
<form class="search" on:submit={applySearch}>
|
||||
<input type="search"
|
||||
bind:value={search}
|
||||
placeholder={$t('parts.search_placeholder')} />
|
||||
<button type="submit">{$t('common.search')}</button>
|
||||
{#if search}
|
||||
<button type="button" class="secondary"
|
||||
on:click={() => { search = ''; applySearch(); }}>
|
||||
{$t('common.clear')}
|
||||
</button>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if parts.length === 0}
|
||||
<p class="muted card">{$t('parts.no_results')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<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>{$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('reorder_level')}>{$t('parts.reorder_level')} {arrow('reorder_level')}</button></th>
|
||||
<th class="num"><button class="th-btn" on:click={() => sortBy('sale_price')}>{$t('parts.sale_price')} {arrow('sale_price')}</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each parts as p}
|
||||
<tr>
|
||||
<td><a href="/parts/{p.id}">{p.sku}</a></td>
|
||||
<td>
|
||||
{localized(p, 'name', lang)}
|
||||
{#if !hasTranslation(p, 'name', lang)}
|
||||
<em class="missing">{$t('common.missing_translation')}</em>
|
||||
{/if}
|
||||
</td>
|
||||
<td>{localized({name_en: p.category_name_en, name_tg: p.category_name_tg}, 'name', lang) || $t('common.none')}</td>
|
||||
<td class="num">
|
||||
{#if p.quantity_on_hand <= p.reorder_level}
|
||||
<span class="pill low">{p.quantity_on_hand}</span>
|
||||
{:else}
|
||||
{p.quantity_on_hand}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="num">{p.reorder_level}</td>
|
||||
<td class="num">{formatMoney(p.sale_price, lang)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.add-btn {
|
||||
background: #006a4e;
|
||||
color: #fff;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.add-btn:hover { background: #00553e; color: #fff; }
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
.search input { flex: 1; }
|
||||
.th-btn {
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.th-btn:hover { color: #006a4e; background: transparent; }
|
||||
.missing {
|
||||
color: #8a6f1b;
|
||||
font-size: 0.8em;
|
||||
margin-left: 0.35rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
34
src/routes/parts/[id]/+page.server.js
Normal file
34
src/routes/parts/[id]/+page.server.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { getPart, getPartBySku, listCategories, updatePart } from '$lib/server/parts.js';
|
||||
import { recentMovementsForPart } from '$lib/server/movements.js';
|
||||
|
||||
export function load({ params }) {
|
||||
const id = Number(params.id);
|
||||
const part = getPart(id);
|
||||
if (!part) throw error(404, 'Part not found');
|
||||
return {
|
||||
part,
|
||||
categories: listCategories(),
|
||||
movements: recentMovementsForPart(id, 25)
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, params }) => {
|
||||
const id = Number(params.id);
|
||||
const form = await request.formData();
|
||||
const data = Object.fromEntries(form);
|
||||
|
||||
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())) {
|
||||
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 });
|
||||
|
||||
updatePart(id, data);
|
||||
throw redirect(303, `/parts/${id}`);
|
||||
}
|
||||
};
|
||||
195
src/routes/parts/[id]/+page.svelte
Normal file
195
src/routes/parts/[id]/+page.svelte
Normal file
@ -0,0 +1,195 @@
|
||||
<script>
|
||||
import { locale, t, localized, formatMoney } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: lang = $locale;
|
||||
$: ({ part, categories, movements } = data);
|
||||
|
||||
$: errors = form?.errors ?? {};
|
||||
$: values = form?.values ?? {};
|
||||
|
||||
// Display somoni (not dirams) in the form.
|
||||
function asSomoni(dirams) {
|
||||
if (dirams == null) return '';
|
||||
return (Number(dirams) / 100).toFixed(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page-head">
|
||||
<h1>{$t('parts.edit')}: {part.sku}</h1>
|
||||
<a href="/parts" class="muted">← {$t('common.back')}</a>
|
||||
</div>
|
||||
|
||||
{#if errors.name}
|
||||
<div class="error">{$t(errors.name)}</div>
|
||||
{/if}
|
||||
|
||||
<div class="layout">
|
||||
<section>
|
||||
<form class="stack" method="POST">
|
||||
<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">
|
||||
<label>
|
||||
{$t('parts.name_en')}
|
||||
<input name="name_en" value={values.name_en ?? part.name_en} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.name_tg')}
|
||||
<input name="name_tg" value={values.name_tg ?? part.name_tg} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
{$t('parts.category')}
|
||||
<select name="category_id">
|
||||
<option value="">—</option>
|
||||
{#each categories as c}
|
||||
<option value={c.id}
|
||||
selected={String(values.category_id ?? part.category_id) === String(c.id)}>
|
||||
{localized(c, 'name', lang)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.cost_price')} ({$t('common.currency_short')})
|
||||
<input name="cost_price" type="number" step="0.01" min="0"
|
||||
value={values.cost_price ?? asSomoni(part.cost_price)} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.sale_price')} ({$t('common.currency_short')})
|
||||
<input name="sale_price" type="number" step="0.01" min="0"
|
||||
value={values.sale_price ?? asSomoni(part.sale_price)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.unit')}
|
||||
<input name="unit" value={values.unit ?? part.unit} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.reorder_level')}
|
||||
<input name="reorder_level" type="number" min="0" step="1"
|
||||
value={values.reorder_level ?? part.reorder_level} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.location')}
|
||||
<input name="location" value={values.location ?? part.location ?? ''} />
|
||||
</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">
|
||||
<input type="checkbox" name="active" value="1"
|
||||
checked={values.active != null ? values.active === '1' : !!part.active} />
|
||||
{$t('parts.active')}
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">{$t('common.save')}</button>
|
||||
<a class="btn-link" href="/movements/new?part_id={part.id}">+ {$t('nav.new_movement')}</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
<div class="card">
|
||||
<div class="muted">{$t('parts.quantity_on_hand')}</div>
|
||||
<div class="qty" class:low={part.quantity_on_hand <= part.reorder_level}>
|
||||
{part.quantity_on_hand}
|
||||
<span class="unit">{part.unit}</span>
|
||||
</div>
|
||||
<div class="muted small">
|
||||
{$t('parts.reorder_level')}: {part.reorder_level}
|
||||
</div>
|
||||
<hr />
|
||||
<div class="muted small">{$t('common.created')}: {part.created_at}</div>
|
||||
<div class="muted small">{$t('common.updated')}: {part.updated_at}</div>
|
||||
</div>
|
||||
|
||||
<h2>{$t('parts.recent_movements')}</h2>
|
||||
{#if movements.length === 0}
|
||||
<p class="muted">{$t('movements.no_movements')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('movements.created_at')}</th>
|
||||
<th>{$t('movements.type')}</th>
|
||||
<th class="num">{$t('movements.quantity')}</th>
|
||||
<th class="num">{$t('movements.unit_price')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each movements as m}
|
||||
<tr>
|
||||
<td>{m.created_at}</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.unit_price != null ? formatMoney(m.unit_price, lang) : $t('common.none')}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-head { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
.actions { display: flex; gap: 1rem; align-items: center; }
|
||||
.btn-link {
|
||||
margin-left: auto; /* push to the right edge of the form */
|
||||
background: #006a4e;
|
||||
color: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
}
|
||||
.btn-link:hover { background: #00553e; color: #fff; }
|
||||
.checkbox { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.field-error { color: #8a1f1b; font-size: 0.8rem; }
|
||||
|
||||
.qty { font-size: 2rem; font-weight: 700; margin: 0.25rem 0; }
|
||||
.qty.low { color: #b8443f; }
|
||||
.qty .unit { font-size: 0.9rem; color: #6b7388; font-weight: 400; }
|
||||
.small { font-size: 0.8rem; }
|
||||
hr { border: 0; border-top: 1px solid #eef0f5; margin: 0.6rem 0; }
|
||||
</style>
|
||||
46
src/routes/parts/new/+page.server.js
Normal file
46
src/routes/parts/new/+page.server.js
Normal file
@ -0,0 +1,46 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { createPart, getPartBySku, listCategories } from '$lib/server/parts.js';
|
||||
import { recordMovement } from '$lib/server/movements.js';
|
||||
|
||||
export function load() {
|
||||
return { categories: listCategories() };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const data = Object.fromEntries(form);
|
||||
const errors = validate(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
|
||||
// if the user supplied an initial quantity. This keeps quantity changes
|
||||
// funneled exclusively through stock_movements.
|
||||
const initialQty = Number(data.quantity_on_hand || 0);
|
||||
const id = createPart({ ...data, quantity_on_hand: 0 });
|
||||
if (initialQty > 0) {
|
||||
recordMovement({
|
||||
part_id: id,
|
||||
movement_type: 'in',
|
||||
quantity: initialQty,
|
||||
unit_price: data.cost_price,
|
||||
reference: 'OPENING'
|
||||
});
|
||||
}
|
||||
|
||||
throw redirect(303, `/parts/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
function validate(d) {
|
||||
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())) {
|
||||
errors.name = 'parts.errors.name_required';
|
||||
}
|
||||
return Object.keys(errors).length ? errors : null;
|
||||
}
|
||||
108
src/routes/parts/new/+page.svelte
Normal file
108
src/routes/parts/new/+page.svelte
Normal file
@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import { locale, t, localized } from '$lib/i18n/store.js';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: lang = $locale;
|
||||
$: ({ categories } = data);
|
||||
|
||||
$: errors = form?.errors ?? {};
|
||||
$: values = form?.values ?? {};
|
||||
</script>
|
||||
|
||||
<h1>{$t('parts.new')}</h1>
|
||||
|
||||
{#if errors.name}
|
||||
<div class="error">{$t(errors.name)}</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
<label>
|
||||
{$t('parts.name_en')}
|
||||
<input name="name_en" value={values.name_en ?? ''} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.name_tg')}
|
||||
<input name="name_tg" value={values.name_tg ?? ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
{$t('parts.category')}
|
||||
<select name="category_id">
|
||||
<option value="">—</option>
|
||||
{#each categories as c}
|
||||
<option value={c.id} selected={String(values.category_id) === String(c.id)}>
|
||||
{localized(c, 'name', lang)}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.cost_price')} ({$t('common.currency_short')})
|
||||
<input name="cost_price" type="number" step="0.01" min="0" value={values.cost_price ?? ''} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.sale_price')} ({$t('common.currency_short')})
|
||||
<input name="sale_price" type="number" step="0.01" min="0" value={values.sale_price ?? ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.unit')}
|
||||
<input name="unit" value={values.unit ?? 'pcs'} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.reorder_level')}
|
||||
<input name="reorder_level" type="number" min="0" step="1" value={values.reorder_level ?? 0} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('parts.initial_quantity')}
|
||||
<input name="quantity_on_hand" type="number" min="0" step="1" value={values.quantity_on_hand ?? 0} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('parts.location')}
|
||||
<input name="location" value={values.location ?? ''} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">{$t('common.save')}</button>
|
||||
<a href="/parts" class="cancel">{$t('common.cancel')}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.actions { display: flex; gap: 1rem; align-items: center; }
|
||||
.cancel { color: #6b7388; text-decoration: none; }
|
||||
.field-error { color: #8a1f1b; font-size: 0.8rem; }
|
||||
</style>
|
||||
24
src/routes/suppliers/+page.server.js
Normal file
24
src/routes/suppliers/+page.server.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { listSuppliers, createSupplier, deleteSupplier } from '$lib/server/suppliers.js';
|
||||
|
||||
export function load() {
|
||||
return { suppliers: listSuppliers() };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
create: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const data = Object.fromEntries(form);
|
||||
if (!data.name || !data.name.trim()) {
|
||||
return fail(400, { errors: { name: 'suppliers.errors.name_required' }, values: data });
|
||||
}
|
||||
createSupplier(data);
|
||||
return { ok: true };
|
||||
},
|
||||
delete: async ({ request }) => {
|
||||
const form = await request.formData();
|
||||
const id = Number(form.get('id'));
|
||||
if (id) deleteSupplier(id);
|
||||
return { ok: true };
|
||||
}
|
||||
};
|
||||
75
src/routes/suppliers/+page.svelte
Normal file
75
src/routes/suppliers/+page.svelte
Normal file
@ -0,0 +1,75 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n/store.js';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
export let data;
|
||||
export let form;
|
||||
$: ({ suppliers } = data);
|
||||
$: errors = form?.errors ?? {};
|
||||
$: values = form?.values ?? {};
|
||||
</script>
|
||||
|
||||
<h1>{$t('suppliers.title')}</h1>
|
||||
|
||||
{#if suppliers.length === 0}
|
||||
<p class="muted">{$t('suppliers.no_suppliers')}</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t('suppliers.name')}</th>
|
||||
<th>{$t('suppliers.phone')}</th>
|
||||
<th>{$t('suppliers.address')}</th>
|
||||
<th>{$t('suppliers.notes')}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each suppliers as s}
|
||||
<tr>
|
||||
<td><strong>{s.name}</strong></td>
|
||||
<td>{s.phone || $t('common.none')}</td>
|
||||
<td>{s.address || $t('common.none')}</td>
|
||||
<td>{s.notes || $t('common.none')}</td>
|
||||
<td>
|
||||
<form method="POST" action="?/delete" use:enhance
|
||||
on:submit={(e) => { if (!confirm($t('suppliers.delete_confirm'))) e.preventDefault(); }}>
|
||||
<input type="hidden" name="id" value={s.id} />
|
||||
<button class="danger" type="submit">{$t('common.delete')}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
|
||||
<h2>{$t('suppliers.add')}</h2>
|
||||
<form class="stack" method="POST" action="?/create" use:enhance>
|
||||
<label>
|
||||
{$t('suppliers.name')} *
|
||||
<input name="name" required value={values.name ?? ''} />
|
||||
{#if errors.name}<span class="field-error">{$t(errors.name)}</span>{/if}
|
||||
</label>
|
||||
<div class="row">
|
||||
<label>
|
||||
{$t('suppliers.phone')}
|
||||
<input name="phone" value={values.phone ?? ''} />
|
||||
</label>
|
||||
<label>
|
||||
{$t('suppliers.address')}
|
||||
<input name="address" value={values.address ?? ''} />
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
{$t('suppliers.notes')}
|
||||
<textarea name="notes">{values.notes ?? ''}</textarea>
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit">{$t('common.add')}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.field-error { color: #8a1f1b; font-size: 0.8rem; }
|
||||
</style>
|
||||
10
svelte.config.js
Normal file
10
svelte.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({ out: 'build' })
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
24
vite.config.js
Normal file
24
vite.config.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
},
|
||||
// better-sqlite3 is native — keep it out of Vite's optimizer
|
||||
ssr: {
|
||||
noExternal: [],
|
||||
external: ['better-sqlite3']
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['better-sqlite3']
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user