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:
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "Ном",
|
||||
|
||||
45
src/lib/server/categories.js
Normal file
45
src/lib/server/categories.js
Normal 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
|
||||
};
|
||||
}
|
||||
52
src/routes/categories/+page.server.js
Normal file
52
src/routes/categories/+page.server.js
Normal 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 };
|
||||
}
|
||||
};
|
||||
111
src/routes/categories/+page.svelte
Normal file
111
src/routes/categories/+page.svelte
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user