feat(acquisition): add category grouping for acquisition methods

Add category field to acquisition_methods table with 'General' default
Group acquisition methods by category in item and recipe detail views
Enable category management in admin configuration
This commit is contained in:
2026-05-13 15:32:07 +08:00
parent 5c72766781
commit c15905bafd
8 changed files with 147 additions and 18 deletions

View File

@@ -1169,6 +1169,7 @@ CREATE TABLE IF NOT EXISTS pokemon_favorite_things (
CREATE TABLE IF NOT EXISTS acquisition_methods (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
category text NOT NULL DEFAULT 'General',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
@@ -1565,6 +1566,9 @@ ALTER TABLE environments
ALTER TABLE favorite_things
ADD COLUMN IF NOT EXISTS opposite_favorite_thing_id integer;
ALTER TABLE acquisition_methods
ADD COLUMN IF NOT EXISTS category text NOT NULL DEFAULT 'General';
DO $$
BEGIN
IF NOT EXISTS (

View File

@@ -155,6 +155,7 @@ type ConfigType =
type ConfigDefinition = {
table: string;
entityType: EntityType;
hasCategory?: boolean;
hasItemDrop?: boolean;
hasTrading?: boolean;
hasChangeLog?: boolean;
@@ -663,6 +664,7 @@ type DailyChecklistChangeSource = {
} & TranslationChangeSource;
type ConfigChangeSource = {
name: string;
category?: string;
description?: string;
opposite?: { name: string } | null;
hasItemDrop?: boolean;
@@ -734,7 +736,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
environments: { table: 'environments', entityType: 'environments', hasDescription: true, oppositeColumn: 'opposite_environment_id' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things', oppositeColumn: 'opposite_favorite_thing_id' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods', hasCategory: true },
maps: { table: 'maps', entityType: 'maps' },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
@@ -1042,10 +1044,12 @@ async function deleteEntityTranslations(client: DbClient, entityType: EntityType
function optionSelect(
tableName: string,
entityType: EntityType,
locale: string
): Promise<Array<{ id: number; name: string }>> {
locale: string,
includeCategory = false
): Promise<Array<{ id: number; name: string; category?: string }>> {
const name = localizedName(entityType, 'o', locale);
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
const columns = includeCategory ? `o.id, ${name} AS name, o.category` : `o.id, ${name} AS name`;
return query(`SELECT ${columns} FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
function systemListLabel(option: SystemListOption, locale: string): string {
@@ -1178,6 +1182,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasDescription) {
columns.push(`c.description`);
}
if (definition.hasCategory) {
columns.push(`c.category`);
}
if (definition.oppositeColumn) {
columns.push(
`
@@ -1247,6 +1254,11 @@ function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanConfigCategory(value: unknown): string {
const category = cleanOptionalText(value);
return category || 'General';
}
function isStaticImageFileName(fileName: string): boolean {
return Boolean(fileName) && !fileName.includes('/') && !fileName.includes('\\') && !fileName.includes('..') && /^[A-Za-z0-9._()-]+$/.test(fileName);
}
@@ -2745,6 +2757,7 @@ function configEditChanges(
before: ConfigChangeSource,
after: {
name: string;
category?: string;
description: string;
translations: TranslationInput;
oppositeName: string;
@@ -2756,6 +2769,9 @@ function configEditChanges(
const changes: EditChange[] = [];
pushChange(changes, 'Name', before.name, after.name);
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
if (definition.hasCategory) {
pushChange(changes, 'Category', before.category, after.category);
}
if (definition.hasDescription) {
pushChange(changes, 'Description', before.description, after.description);
}
@@ -2883,7 +2899,7 @@ export async function getOptions(locale = defaultLocale) {
skillOptions(locale),
optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale, true),
optionSelect('maps', 'maps', locale),
gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale),
@@ -5581,6 +5597,7 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
@@ -5595,6 +5612,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('description');
values.push(description);
}
if (definition.hasCategory) {
columns.push('category');
values.push(category);
}
if (definition.hasItemDrop) {
columns.push('has_item_drop');
values.push(hasItemDrop);
@@ -5653,6 +5674,7 @@ export async function updateConfig(
const definition = configDefinitions[type];
const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']);
const category = definition.hasCategory ? cleanConfigCategory(payload.category) : '';
const description = definition.hasDescription ? cleanOptionalText(payload.description) : '';
const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null;
const before = await getConfigById(type, id, defaultLocale);
@@ -5675,6 +5697,10 @@ export async function updateConfig(
values.push(description);
assignments.push(`description = $${values.length}`);
}
if (definition.hasCategory) {
values.push(category);
assignments.push(`category = $${values.length}`);
}
if (definition.hasItemDrop) {
values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`);
@@ -5735,6 +5761,7 @@ export async function updateConfig(
const changes = before
? configEditChanges(definition, before as ConfigChangeSource, {
name,
category,
description,
translations,
oppositeName: oppositeId ? oppositeNames.get(oppositeId) ?? '' : '',
@@ -6813,7 +6840,7 @@ export async function getItem(id: number, locale = defaultLocale) {
] = await Promise.all([
query(
`
SELECT am.id, ${acquisitionMethodName} AS name
SELECT am.id, ${acquisitionMethodName} AS name, am.category
FROM item_acquisition_methods iam
JOIN acquisition_methods am ON am.id = iam.acquisition_method_id
WHERE iam.item_id = $1
@@ -6828,7 +6855,7 @@ export async function getItem(id: number, locale = defaultLocale) {
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id
@@ -7496,7 +7523,7 @@ export async function getRecipe(id: number, locale = defaultLocale) {
${resultItemName} AS name,
${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')},
COALESCE((
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${orderByEntity('am')})
SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}, 'category', am.category) ORDER BY ${orderByEntity('am')})
FROM recipe_acquisition_methods ram
JOIN acquisition_methods am ON am.id = ram.acquisition_method_id
WHERE ram.recipe_id = r.id