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>
|
<a href="/admin">{$t('nav.admin')}</a>
|
||||||
</nav>
|
</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')}
|
{lang === 'en' ? $t('lang.switch_to_tg') : $t('lang.switch_to_en')}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
},
|
},
|
||||||
"lang": {
|
"lang": {
|
||||||
"switch_to_tg": "Тоҷикӣ",
|
"switch_to_tg": "Тоҷикӣ",
|
||||||
"switch_to_en": "English"
|
"switch_to_en": "English",
|
||||||
|
"switch_aria": "Switch language"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@ -138,6 +139,7 @@
|
|||||||
"cancel_confirm": "Permanently discard this draft? All added lines will be lost.",
|
"cancel_confirm": "Permanently discard this draft? All added lines will be lost.",
|
||||||
"saved_total": "Total",
|
"saved_total": "Total",
|
||||||
"saved_thanks": "Scan the QR code below to pay.",
|
"saved_thanks": "Scan the QR code below to pay.",
|
||||||
|
"qr_alt": "Payment QR code",
|
||||||
"new_another": "Start a new sale",
|
"new_another": "Start a new sale",
|
||||||
"errors": {
|
"errors": {
|
||||||
"part_required": "Pick a part.",
|
"part_required": "Pick a part.",
|
||||||
@ -151,6 +153,22 @@
|
|||||||
"line_missing": "Line not found."
|
"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": {
|
"suppliers": {
|
||||||
"title": "Suppliers",
|
"title": "Suppliers",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
},
|
},
|
||||||
"lang": {
|
"lang": {
|
||||||
"switch_to_tg": "Тоҷикӣ",
|
"switch_to_tg": "Тоҷикӣ",
|
||||||
"switch_to_en": "English"
|
"switch_to_en": "English",
|
||||||
|
"switch_aria": "Иваз кардани забон"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"save": "Захира",
|
"save": "Захира",
|
||||||
@ -138,6 +139,7 @@
|
|||||||
"cancel_confirm": "Ин лоиҳаро пурра нест мекунед? Ҳамаи сатрҳои иловашуда гум мешаванд.",
|
"cancel_confirm": "Ин лоиҳаро пурра нест мекунед? Ҳамаи сатрҳои иловашуда гум мешаванд.",
|
||||||
"saved_total": "Ҳамагӣ",
|
"saved_total": "Ҳамагӣ",
|
||||||
"saved_thanks": "Барои пардохт рамзи QR-ро аз поён скан кунед.",
|
"saved_thanks": "Барои пардохт рамзи QR-ро аз поён скан кунед.",
|
||||||
|
"qr_alt": "Рамзи QR-и пардохт",
|
||||||
"new_another": "Фурӯши нав сар кардан",
|
"new_another": "Фурӯши нав сар кардан",
|
||||||
"errors": {
|
"errors": {
|
||||||
"part_required": "Қисмро интихоб кунед.",
|
"part_required": "Қисмро интихоб кунед.",
|
||||||
@ -151,6 +153,22 @@
|
|||||||
"line_missing": "Сатр ёфт нашуд."
|
"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": {
|
"suppliers": {
|
||||||
"title": "Таъминкунандагон",
|
"title": "Таъминкунандагон",
|
||||||
"name": "Ном",
|
"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">
|
<section class="pay">
|
||||||
<p class="muted">{$t('invoices.saved_thanks')}</p>
|
<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>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user