diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index a40ec2c..b076594 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -21,7 +21,7 @@ {$t('nav.admin')} - + {lang === 'en' ? $t('lang.switch_to_tg') : $t('lang.switch_to_en')} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index c7ad5eb..dc935b8 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -15,7 +15,8 @@ }, "lang": { "switch_to_tg": "Тоҷикӣ", - "switch_to_en": "English" + "switch_to_en": "English", + "switch_aria": "Switch language" }, "common": { "save": "Save", @@ -138,6 +139,7 @@ "cancel_confirm": "Permanently discard this draft? All added lines will be lost.", "saved_total": "Total", "saved_thanks": "Scan the QR code below to pay.", + "qr_alt": "Payment QR code", "new_another": "Start a new sale", "errors": { "part_required": "Pick a part.", @@ -151,6 +153,22 @@ "line_missing": "Line not found." } }, + "categories": { + "title": "Categories", + "intro": "Deleting a category does not delete its parts; they become uncategorized.", + "sort": "Sort", + "sort_order": "Sort order", + "part_count": "Parts", + "add": "Add a category", + "add_button": "Add category", + "delete_confirm": "Delete \"{name}\"?", + "delete_confirm_with_parts": "Delete \"{name}\"? {count} part(s) will become uncategorized (not deleted).", + "errors": { + "name_required": "At least one name (English or Tajik) is required.", + "sort_invalid": "Sort order must be a number.", + "id_missing": "Missing category id." + } + }, "suppliers": { "title": "Suppliers", "name": "Name", diff --git a/src/lib/i18n/tg.json b/src/lib/i18n/tg.json index 861fdb7..e8d9a7b 100644 --- a/src/lib/i18n/tg.json +++ b/src/lib/i18n/tg.json @@ -15,7 +15,8 @@ }, "lang": { "switch_to_tg": "Тоҷикӣ", - "switch_to_en": "English" + "switch_to_en": "English", + "switch_aria": "Иваз кардани забон" }, "common": { "save": "Захира", @@ -138,6 +139,7 @@ "cancel_confirm": "Ин лоиҳаро пурра нест мекунед? Ҳамаи сатрҳои иловашуда гум мешаванд.", "saved_total": "Ҳамагӣ", "saved_thanks": "Барои пардохт рамзи QR-ро аз поён скан кунед.", + "qr_alt": "Рамзи QR-и пардохт", "new_another": "Фурӯши нав сар кардан", "errors": { "part_required": "Қисмро интихоб кунед.", @@ -151,6 +153,22 @@ "line_missing": "Сатр ёфт нашуд." } }, + "categories": { + "title": "Категорияҳо", + "intro": "Несткунии категория қисмҳои онро нест намекунад; онҳо бе категория мемонанд.", + "sort": "Тартиб", + "sort_order": "Рақами тартиб", + "part_count": "Қисмҳо", + "add": "Илова кардани категория", + "add_button": "Илова кардан", + "delete_confirm": "«{name}»-ро нест мекунед?", + "delete_confirm_with_parts": "«{name}»-ро нест мекунед? {count} қисм бе категория мемонанд (нест намешаванд).", + "errors": { + "name_required": "Ҳадди ақалл як ном (англисӣ ё тоҷикӣ) зарур аст.", + "sort_invalid": "Рақами тартиб бояд адад бошад.", + "id_missing": "Шиносаи категория ёфт нашуд." + } + }, "suppliers": { "title": "Таъминкунандагон", "name": "Ном", diff --git a/src/lib/server/categories.js b/src/lib/server/categories.js new file mode 100644 index 0000000..277b53a --- /dev/null +++ b/src/lib/server/categories.js @@ -0,0 +1,45 @@ +import { getDb } from './db.js'; + +export function listCategoriesWithCounts() { + return getDb().prepare(` + SELECT c.*, COALESCE(COUNT(p.id), 0) AS part_count + FROM categories c + LEFT JOIN parts p ON p.category_id = c.id + GROUP BY c.id + ORDER BY c.sort_order, c.name_en + `).all(); +} + +export function getCategory(id) { + return getDb().prepare(`SELECT * FROM categories WHERE id = ?`).get(Number(id)); +} + +export function createCategory(input) { + const stmt = getDb().prepare(` + INSERT INTO categories (name_en, name_tg, sort_order) + VALUES (@name_en, @name_tg, @sort_order) + `); + return stmt.run(normalize(input)).lastInsertRowid; +} + +export function updateCategory(id, input) { + getDb().prepare(` + UPDATE categories + SET name_en = @name_en, name_tg = @name_tg, sort_order = @sort_order + WHERE id = @id + `).run({ ...normalize(input), id: Number(id) }); +} + +// parts.category_id has ON DELETE SET NULL, so deleting a category leaves +// its parts in place (just uncategorized). +export function deleteCategory(id) { + getDb().prepare(`DELETE FROM categories WHERE id = ?`).run(Number(id)); +} + +function normalize(c) { + return { + name_en: (c.name_en || '').trim(), + name_tg: (c.name_tg || '').trim(), + sort_order: Number.isFinite(Number(c.sort_order)) ? Number(c.sort_order) : 0 + }; +} diff --git a/src/routes/categories/+page.server.js b/src/routes/categories/+page.server.js new file mode 100644 index 0000000..1784fe2 --- /dev/null +++ b/src/routes/categories/+page.server.js @@ -0,0 +1,52 @@ +import { fail } from '@sveltejs/kit'; +import { + listCategoriesWithCounts, + createCategory, + updateCategory, + deleteCategory +} from '$lib/server/categories.js'; + +export function load() { + return { categories: listCategoriesWithCounts() }; +} + +function validate(data) { + const errors = {}; + if (!data.name_en?.trim() && !data.name_tg?.trim()) { + errors.name = 'categories.errors.name_required'; + } + const sort = Number(data.sort_order); + if (data.sort_order !== '' && data.sort_order != null && !Number.isFinite(sort)) { + errors.sort_order = 'categories.errors.sort_invalid'; + } + return errors; +} + +export const actions = { + create: async ({ request }) => { + const data = Object.fromEntries(await request.formData()); + const errors = validate(data); + if (Object.keys(errors).length) return fail(400, { action: 'create', errors, values: data }); + createCategory(data); + return { ok: true }; + }, + + update: async ({ request }) => { + const data = Object.fromEntries(await request.formData()); + const id = Number(data.id); + if (!id) return fail(400, { errors: { id: 'categories.errors.id_missing' } }); + const errors = validate(data); + if (Object.keys(errors).length) { + return fail(400, { action: 'update', id, errors, values: data }); + } + updateCategory(id, data); + return { ok: true, updatedId: id }; + }, + + delete: async ({ request }) => { + const data = Object.fromEntries(await request.formData()); + const id = Number(data.id); + if (id) deleteCategory(id); + return { ok: true }; + } +}; diff --git a/src/routes/categories/+page.svelte b/src/routes/categories/+page.svelte new file mode 100644 index 0000000..270cd84 --- /dev/null +++ b/src/routes/categories/+page.svelte @@ -0,0 +1,111 @@ + + +{$t('categories.title')} + +{$t('categories.intro')} + + +{#each categories as c (c.id)} + + + + + + +{/each} + + + + + {$t('categories.sort')} + {$t('parts.name_en')} + {$t('parts.name_tg')} + {$t('categories.part_count')} + + + + + {#each categories as c (c.id)} + {@const errs = rowErrors(c.id)} + + + + + + + + + + + {c.part_count} + + {$t('common.save')} + {$t('common.delete')} + {#if errs.name}{$t(errs.name)}{/if} + {#if errs.sort_order}{$t(errs.sort_order)}{/if} + + + {/each} + + + +{$t('categories.add')} + + + + {$t('parts.name_en')} + + + + {$t('parts.name_tg')} + + + + + {$t('categories.sort_order')} + + + {#if createErrors.name}{$t(createErrors.name)}{/if} + {#if createErrors.sort_order}{$t(createErrors.sort_order)}{/if} + + {$t('categories.add_button')} + + + + diff --git a/src/routes/invoices/[id]/+page.svelte b/src/routes/invoices/[id]/+page.svelte index 3c8df12..13ac0ea 100644 --- a/src/routes/invoices/[id]/+page.svelte +++ b/src/routes/invoices/[id]/+page.svelte @@ -52,7 +52,7 @@ {$t('invoices.saved_thanks')} - +
{$t('categories.intro')}
{$t('invoices.saved_thanks')}