Add /categories CRUD admin page and localize remaining English strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Beccue
2026-05-16 13:42:51 +05:00
parent d7dfad9a24
commit f42219b442
7 changed files with 248 additions and 4 deletions

View File

@ -21,7 +21,7 @@
<a href="/admin">{$t('nav.admin')}</a>
</nav>
<button class="lang" type="button" on:click={toggleLocale} aria-label="Switch language">
<button class="lang" type="button" on:click={toggleLocale} aria-label={$t('lang.switch_aria')}>
{lang === 'en' ? $t('lang.switch_to_tg') : $t('lang.switch_to_en')}
</button>
</header>

View File

@ -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",

View File

@ -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": "Ном",

View File

@ -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
};
}

View File

@ -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 };
}
};

View File

@ -0,0 +1,111 @@
<script>
import { enhance } from '$app/forms';
import { locale, t, localized } from '$lib/i18n/store.js';
export let data;
export let form;
$: lang = $locale;
$: ({ categories } = data);
$: createErrors = form?.action === 'create' ? (form.errors ?? {}) : {};
$: createValues = form?.action === 'create' ? (form.values ?? {}) : {};
function rowErrors(id) {
return form?.action === 'update' && form.id === id ? (form.errors ?? {}) : {};
}
function confirmDelete(cat) {
const name = localized(cat, 'name', lang) || cat.name_en || cat.name_tg;
const template = cat.part_count > 0
? $t('categories.delete_confirm_with_parts')
: $t('categories.delete_confirm');
const msg = template.replace('{name}', name).replace('{count}', cat.part_count);
return (e) => { if (!confirm(msg)) e.preventDefault(); };
}
</script>
<h1>{$t('categories.title')}</h1>
<p class="muted">{$t('categories.intro')}</p>
<!--
The update and delete forms live outside the table because a <form> is not
permitted as a child of <tr>. Inputs inside the rows associate via the
HTML `form` attribute.
-->
{#each categories as c (c.id)}
<form method="POST" action="?/update" use:enhance id={`row-${c.id}`} hidden>
<input type="hidden" name="id" value={c.id} />
</form>
<form method="POST" action="?/delete" use:enhance id={`del-${c.id}`}
on:submit={confirmDelete(c)} hidden>
<input type="hidden" name="id" value={c.id} />
</form>
{/each}
<table>
<thead>
<tr>
<th class="num">{$t('categories.sort')}</th>
<th>{$t('parts.name_en')}</th>
<th>{$t('parts.name_tg')}</th>
<th class="num">{$t('categories.part_count')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each categories as c (c.id)}
{@const errs = rowErrors(c.id)}
<tr>
<td class="num">
<input form={`row-${c.id}`} name="sort_order" type="number" step="1"
value={c.sort_order} class="sort-input" />
</td>
<td>
<input form={`row-${c.id}`} name="name_en" value={c.name_en ?? ''} />
</td>
<td>
<input form={`row-${c.id}`} name="name_tg" value={c.name_tg ?? ''} />
</td>
<td class="num">{c.part_count}</td>
<td class="actions">
<button form={`row-${c.id}`} type="submit">{$t('common.save')}</button>
<button form={`del-${c.id}`} type="submit" class="danger">{$t('common.delete')}</button>
{#if errs.name}<div class="field-error">{$t(errs.name)}</div>{/if}
{#if errs.sort_order}<div class="field-error">{$t(errs.sort_order)}</div>{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<h2>{$t('categories.add')}</h2>
<form class="stack" method="POST" action="?/create" use:enhance>
<div class="row">
<label>
{$t('parts.name_en')}
<input name="name_en" value={createValues.name_en ?? ''} />
</label>
<label>
{$t('parts.name_tg')}
<input name="name_tg" value={createValues.name_tg ?? ''} />
</label>
</div>
<label>
{$t('categories.sort_order')}
<input name="sort_order" type="number" step="1" value={createValues.sort_order ?? '0'} />
</label>
{#if createErrors.name}<span class="field-error">{$t(createErrors.name)}</span>{/if}
{#if createErrors.sort_order}<span class="field-error">{$t(createErrors.sort_order)}</span>{/if}
<div>
<button type="submit">{$t('categories.add_button')}</button>
</div>
</form>
<style>
.actions { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; }
.sort-input { width: 4.5rem; text-align: right; }
.field-error { color: #8a1f1b; font-size: 0.8rem; width: 100%; }
td input { width: 100%; }
td.num input { width: auto; }
</style>

View File

@ -52,7 +52,7 @@
<section class="pay">
<p class="muted">{$t('invoices.saved_thanks')}</p>
<img src="/payment-qr.png" alt="Payment QR code" class="qr" />
<img src="/payment-qr.png" alt={$t('invoices.qr_alt')} class="qr" />
</section>
</article>