diff --git a/award-definitions/qo100grids.json b/award-definitions/qo100grids.json new file mode 100644 index 0000000..1ce8e0f --- /dev/null +++ b/award-definitions/qo100grids.json @@ -0,0 +1,25 @@ +{ + "id": "qo100grids", + "name": "QO100 Grids", + "description": "Work as much Grids as possible on QO100 Satellite", + "caption": "Work as much Grids as possible on QO100 Satellite", + "category": "satellite", + "rules": { + "type": "entity", + "satellite_only": true, + "filters": { + "operator": "AND", + "filters": [ + { + "field": "satName", + "operator": "eq", + "value": "QO-100" + } + ] + }, + "entityType": "grid", + "target": 100, + "displayField": "grid" + }, + "modeGroups": {} +} \ No newline at end of file diff --git a/src/backend/index.js b/src/backend/index.js index 88c25a1..21406fa 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -44,6 +44,14 @@ import { getAwardProgressDetails, getAwardEntityBreakdown, } from './services/awards.service.js'; +import { + getAllAwardDefinitions, + getAwardDefinition, + createAwardDefinition, + updateAwardDefinition, + deleteAwardDefinition, + testAwardCalculation, +} from './services/awards-admin.service.js'; import { getAutoSyncSettings, updateAutoSyncSettings, @@ -1445,6 +1453,220 @@ const app = new Elysia() } }) + /** + * ================================================================ + * AWARD MANAGEMENT ROUTES (Admin Only) + * ================================================================ + */ + + /** + * GET /api/admin/awards + * Get all award definitions (admin only) + */ + .get('/api/admin/awards', async ({ user, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const awards = await getAllAwardDefinitions(); + return { + success: true, + awards, + }; + } catch (error) { + logger.error('Error fetching award definitions', { error: error.message, userId: user.id }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch award definitions', + }; + } + }) + + /** + * GET /api/admin/awards/:id + * Get a single award definition (admin only) + */ + .get('/api/admin/awards/:id', async ({ user, params, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const award = await getAwardDefinition(params.id); + + if (!award) { + set.status = 404; + return { + success: false, + error: 'Award not found', + }; + } + + return { + success: true, + award, + }; + } catch (error) { + logger.error('Error fetching award definition', { error: error.message, userId: user.id }); + set.status = 500; + return { + success: false, + error: 'Failed to fetch award definition', + }; + } + }) + + /** + * POST /api/admin/awards + * Create a new award definition (admin only) + */ + .post( + '/api/admin/awards', + async ({ user, body, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const award = await createAwardDefinition(body); + return { + success: true, + award, + message: 'Award definition created successfully', + }; + } catch (error) { + logger.error('Error creating award definition', { error: error.message, userId: user.id }); + set.status = 400; + return { + success: false, + error: error.message, + }; + } + }, + { + body: t.Object({ + id: t.String(), + name: t.String(), + description: t.String(), + caption: t.String(), + category: t.String(), + rules: t.Any(), + modeGroups: t.Optional(t.Any()), + }), + } + ) + + /** + * PUT /api/admin/awards/:id + * Update an award definition (admin only) + */ + .put( + '/api/admin/awards/:id', + async ({ user, params, body, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const award = await updateAwardDefinition(params.id, body); + return { + success: true, + award, + message: 'Award definition updated successfully', + }; + } catch (error) { + logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id }); + set.status = 400; + return { + success: false, + error: error.message, + }; + } + }, + { + body: t.Object({ + id: t.Optional(t.String()), + name: t.String(), + description: t.String(), + caption: t.String(), + category: t.String(), + rules: t.Any(), + modeGroups: t.Optional(t.Any()), + }), + } + ) + + /** + * DELETE /api/admin/awards/:id + * Delete an award definition (admin only) + */ + .delete('/api/admin/awards/:id', async ({ user, params, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + const result = await deleteAwardDefinition(params.id); + return { + success: true, + ...result, + message: 'Award definition deleted successfully', + }; + } catch (error) { + logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id }); + set.status = 400; + return { + success: false, + error: error.message, + }; + } + }) + + /** + * POST /api/admin/awards/:id/test + * Test award calculation (admin only) + */ + .post( + '/api/admin/awards/:id/test', + async ({ user, params, body, set }) => { + if (!user || !user.isAdmin) { + set.status = !user ? 401 : 403; + return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' }; + } + + try { + // Use provided userId or admin's own account + const testUserId = body.userId || user.id; + const awardDefinition = body.awardDefinition || null; + const result = await testAwardCalculation(params.id, testUserId, awardDefinition); + return { + success: true, + ...result, + }; + } catch (error) { + logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id }); + set.status = 400; + return { + success: false, + error: error.message, + }; + } + }, + { + body: t.Object({ + userId: t.Optional(t.Integer()), + awardDefinition: t.Optional(t.Any()), + }), + } + ) + /** * ================================================================ * AUTO-SYNC SETTINGS ROUTES diff --git a/src/backend/services/awards-admin.service.js b/src/backend/services/awards-admin.service.js new file mode 100644 index 0000000..b0dca75 --- /dev/null +++ b/src/backend/services/awards-admin.service.js @@ -0,0 +1,547 @@ +import { readFileSync, writeFileSync, readdirSync, unlinkSync, existsSync } from 'fs'; +import { join } from 'path'; +import { logger } from '../config.js'; +import { calculateAwardProgress, getAwardById, clearAwardCache } from './awards.service.js'; + +/** + * Awards Admin Service + * Manages award definition JSON files for admin operations + */ + +const AWARD_DEFINITIONS_DIR = join(process.cwd(), 'award-definitions'); + +// Valid entity types for entity rule type +const VALID_ENTITY_TYPES = ['dxcc', 'state', 'grid', 'callsign']; + +// Valid rule types +const VALID_RULE_TYPES = ['entity', 'dok', 'points', 'filtered', 'counter']; + +// Valid count modes for points rule type +const VALID_COUNT_MODES = ['perStation', 'perBandMode', 'perQso']; + +// Valid filter operators +const VALID_FILTER_OPERATORS = ['eq', 'ne', 'in', 'nin', 'contains']; + +// Valid filter fields +const VALID_FILTER_FIELDS = [ + 'band', 'mode', 'callsign', 'entity', 'entityId', 'state', 'grid', 'satName', 'satellite' +]; + +// Valid bands +const VALID_BANDS = [ + '160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', + '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm' +]; + +// Valid modes +const VALID_MODES = [ + 'CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', + 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144' +]; + +/** + * Load all award definitions with file metadata + */ +export async function getAllAwardDefinitions() { + const definitions = []; + + try { + const files = readdirSync(AWARD_DEFINITIONS_DIR) + .filter(f => f.endsWith('.json')) + .sort(); + + for (const file of files) { + try { + const filePath = join(AWARD_DEFINITIONS_DIR, file); + const content = readFileSync(filePath, 'utf-8'); + const definition = JSON.parse(content); + + // Add file metadata + definitions.push({ + ...definition, + _filename: file, + _filepath: filePath, + }); + } catch (error) { + logger.warn('Failed to load award definition', { file, error: error.message }); + } + } + } catch (error) { + logger.error('Error reading award definitions directory', { error: error.message }); + } + + return definitions; +} + +/** + * Get a single award definition by ID + */ +export async function getAwardDefinition(id) { + const definitions = await getAllAwardDefinitions(); + return definitions.find(def => def.id === id) || null; +} + +/** + * Validate an award definition + * @returns {Object} { valid: boolean, errors: string[], warnings: string[] } + */ +export function validateAwardDefinition(definition, existingDefinitions = []) { + const errors = []; + const warnings = []; + + // Check required top-level fields + const requiredFields = ['id', 'name', 'description', 'caption', 'category', 'rules']; + for (const field of requiredFields) { + if (!definition[field]) { + errors.push(`Missing required field: ${field}`); + } + } + + // Validate ID + if (definition.id) { + if (typeof definition.id !== 'string') { + errors.push('ID must be a string'); + } else if (!/^[a-z0-9-]+$/.test(definition.id)) { + errors.push('ID must contain only lowercase letters, numbers, and hyphens'); + } else { + // Check for duplicate ID (unless updating existing award) + const existingIds = existingDefinitions.map(d => d.id); + const isUpdate = existingDefinitions.find(d => d.id === definition.id); + const duplicates = existingDefinitions.filter(d => d.id === definition.id); + if (duplicates.length > 1 || (duplicates.length === 1 && !isUpdate)) { + errors.push(`Award ID "${definition.id}" already exists`); + } + } + } + + // Validate name + if (definition.name && typeof definition.name !== 'string') { + errors.push('Name must be a string'); + } + + // Validate description + if (definition.description && typeof definition.description !== 'string') { + errors.push('Description must be a string'); + } + + // Validate caption + if (definition.caption && typeof definition.caption !== 'string') { + errors.push('Caption must be a string'); + } + + // Validate category + if (definition.category && typeof definition.category !== 'string') { + errors.push('Category must be a string'); + } + + // Validate modeGroups if present + if (definition.modeGroups) { + if (typeof definition.modeGroups !== 'object') { + errors.push('modeGroups must be an object'); + } else { + for (const [groupName, modes] of Object.entries(definition.modeGroups)) { + if (!Array.isArray(modes)) { + errors.push(`modeGroups "${groupName}" must be an array of mode strings`); + } else { + for (const mode of modes) { + if (typeof mode !== 'string') { + errors.push(`mode "${mode}" in group "${groupName}" must be a string`); + } else if (!VALID_MODES.includes(mode)) { + warnings.push(`Unknown mode "${mode}" in group "${groupName}"`); + } + } + } + } + } + } + + // Validate rules + if (!definition.rules) { + errors.push('Rules object is required'); + } else if (typeof definition.rules !== 'object') { + errors.push('Rules must be an object'); + } else { + const ruleValidation = validateRules(definition.rules); + errors.push(...ruleValidation.errors); + warnings.push(...ruleValidation.warnings); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate rules object + */ +function validateRules(rules) { + const errors = []; + const warnings = []; + + // Check rule type + if (!rules.type) { + errors.push('Rules must have a type'); + } else if (!VALID_RULE_TYPES.includes(rules.type)) { + errors.push(`Invalid rule type: ${rules.type}. Must be one of: ${VALID_RULE_TYPES.join(', ')}`); + } + + // Validate based on rule type + switch (rules.type) { + case 'entity': + validateEntityRule(rules, errors, warnings); + break; + case 'dok': + validateDOKRule(rules, errors, warnings); + break; + case 'points': + validatePointsRule(rules, errors, warnings); + break; + case 'filtered': + validateFilteredRule(rules, errors, warnings); + break; + case 'counter': + validateCounterRule(rules, errors, warnings); + break; + } + + // Validate filters if present + if (rules.filters) { + const filterValidation = validateFilters(rules.filters); + errors.push(...filterValidation.errors); + warnings.push(...filterValidation.warnings); + } + + return { errors, warnings }; +} + +/** + * Validate entity rule + */ +function validateEntityRule(rules, errors, warnings) { + if (!rules.entityType) { + errors.push('Entity rule requires entityType'); + } else if (!VALID_ENTITY_TYPES.includes(rules.entityType)) { + errors.push(`Invalid entityType: ${rules.entityType}. Must be one of: ${VALID_ENTITY_TYPES.join(', ')}`); + } + + if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) { + errors.push('Entity rule requires a positive target number'); + } + + if (rules.allowed_bands) { + if (!Array.isArray(rules.allowed_bands)) { + errors.push('allowed_bands must be an array'); + } else { + for (const band of rules.allowed_bands) { + if (!VALID_BANDS.includes(band)) { + warnings.push(`Unknown band in allowed_bands: ${band}`); + } + } + } + } + + if (rules.satellite_only !== undefined && typeof rules.satellite_only !== 'boolean') { + errors.push('satellite_only must be a boolean'); + } + + if (rules.displayField && typeof rules.displayField !== 'string') { + errors.push('displayField must be a string'); + } +} + +/** + * Validate DOK rule + */ +function validateDOKRule(rules, errors, warnings) { + if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) { + errors.push('DOK rule requires a positive target number'); + } + + if (rules.confirmationType && rules.confirmationType !== 'dcl') { + warnings.push('DOK rule confirmationType should be "dcl"'); + } + + if (rules.displayField && typeof rules.displayField !== 'string') { + errors.push('displayField must be a string'); + } +} + +/** + * Validate points rule + */ +function validatePointsRule(rules, errors, warnings) { + if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) { + errors.push('Points rule requires a positive target number'); + } + + if (!rules.stations || !Array.isArray(rules.stations)) { + errors.push('Points rule requires a stations array'); + } else if (rules.stations.length === 0) { + errors.push('Points rule stations array cannot be empty'); + } else { + for (let i = 0; i < rules.stations.length; i++) { + const station = rules.stations[i]; + if (!station.callsign || typeof station.callsign !== 'string') { + errors.push(`Station ${i + 1} missing callsign`); + } + if (typeof station.points !== 'number' || station.points <= 0) { + errors.push(`Station ${i + 1} must have positive points value`); + } + } + } + + if (rules.countMode && !VALID_COUNT_MODES.includes(rules.countMode)) { + errors.push(`Invalid countMode: ${rules.countMode}. Must be one of: ${VALID_COUNT_MODES.join(', ')}`); + } +} + +/** + * Validate filtered rule + */ +function validateFilteredRule(rules, errors, warnings) { + if (!rules.baseRule) { + errors.push('Filtered rule requires baseRule'); + } else { + // Recursively validate base rule + const baseValidation = validateRules(rules.baseRule); + errors.push(...baseValidation.errors); + warnings.push(...baseValidation.warnings); + } + + if (!rules.filters) { + warnings.push('Filtered rule has no filters - baseRule will be used as-is'); + } +} + +/** + * Validate counter rule + */ +function validateCounterRule(rules, errors, warnings) { + if (!rules.target || typeof rules.target !== 'number' || rules.target <= 0) { + errors.push('Counter rule requires a positive target number'); + } + + if (!rules.countBy) { + errors.push('Counter rule requires countBy'); + } else if (!['qso', 'callsign'].includes(rules.countBy)) { + errors.push(`Invalid countBy: ${rules.countBy}. Must be one of: qso, callsign`); + } + + if (rules.displayField && typeof rules.displayField !== 'string') { + errors.push('displayField must be a string'); + } +} + +/** + * Validate filters object + */ +function validateFilters(filters, depth = 0) { + const errors = []; + const warnings = []; + + if (!filters) { + return { errors, warnings }; + } + + // Prevent infinite recursion + if (depth > 10) { + errors.push('Filters are too deeply nested (maximum 10 levels)'); + return { errors, warnings }; + } + + if (filters.operator && !['AND', 'OR'].includes(filters.operator)) { + errors.push(`Invalid filter operator: ${filters.operator}. Must be AND or OR`); + } + + if (filters.filters) { + if (!Array.isArray(filters.filters)) { + errors.push('Filters must be an array'); + } else { + for (const filter of filters.filters) { + if (filter.filters) { + // Nested filter group + const nestedValidation = validateFilters(filter, depth + 1); + errors.push(...nestedValidation.errors); + warnings.push(...nestedValidation.warnings); + } else { + // Leaf filter + if (!filter.field) { + errors.push('Filter missing field'); + } else if (!VALID_FILTER_FIELDS.includes(filter.field)) { + warnings.push(`Unknown filter field: ${filter.field}`); + } + + if (!filter.operator) { + errors.push('Filter missing operator'); + } else if (!VALID_FILTER_OPERATORS.includes(filter.operator)) { + errors.push(`Invalid filter operator: ${filter.operator}`); + } + + if (filter.value === undefined) { + errors.push('Filter missing value'); + } + + if (['in', 'nin'].includes(filter.operator) && !Array.isArray(filter.value)) { + errors.push(`Filter operator ${filter.operator} requires an array value`); + } + } + } + } + } + + return { errors, warnings }; +} + +/** + * Create a new award definition + */ +export async function createAwardDefinition(definition) { + // Get all existing definitions for duplicate check + const existing = await getAllAwardDefinitions(); + + // Validate the definition + const validation = validateAwardDefinition(definition, existing); + if (!validation.valid) { + throw new Error(`Validation failed: ${validation.errors.join('; ')}`); + } + + // Create filename from ID + const filename = `${definition.id}.json`; + const filepath = join(AWARD_DEFINITIONS_DIR, filename); + + // Check if file already exists + if (existsSync(filepath)) { + throw new Error(`Award file "${filename}" already exists`); + } + + // Remove metadata fields before saving + const { _filename, _filepath, ...cleanDefinition } = definition; + + // Write to file + writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8'); + + // Clear the cache so new award is immediately available + clearAwardCache(); + + logger.info('Created award definition', { id: definition.id, filename }); + + return { + ...cleanDefinition, + _filename: filename, + _filepath: filepath, + }; +} + +/** + * Update an existing award definition + */ +export async function updateAwardDefinition(id, updatedDefinition) { + // Get existing definition + const existing = await getAwardDefinition(id); + if (!existing) { + throw new Error(`Award "${id}" not found`); + } + + // Ensure ID matches + if (updatedDefinition.id && updatedDefinition.id !== id) { + throw new Error('Cannot change award ID'); + } + + // Set the ID from the parameter + updatedDefinition.id = id; + + // Get all definitions for validation + const allDefinitions = await getAllAwardDefinitions(); + + // Validate the updated definition + const validation = validateAwardDefinition(updatedDefinition, allDefinitions); + if (!validation.valid) { + throw new Error(`Validation failed: ${validation.errors.join('; ')}`); + } + + // Keep the same filename + const filename = existing._filename; + const filepath = existing._filepath; + + // Remove metadata fields before saving + const { _filename, _filepath, ...cleanDefinition } = updatedDefinition; + + // Write to file + writeFileSync(filepath, JSON.stringify(cleanDefinition, null, 2), 'utf-8'); + + // Clear the cache so updated award is immediately available + clearAwardCache(); + + logger.info('Updated award definition', { id, filename }); + + return { + ...cleanDefinition, + _filename: filename, + _filepath: filepath, + }; +} + +/** + * Delete an award definition + */ +export async function deleteAwardDefinition(id) { + const existing = await getAwardDefinition(id); + if (!existing) { + throw new Error(`Award "${id}" not found`); + } + + // Delete the file + unlinkSync(existing._filepath); + + // Clear the cache so deleted award is immediately removed + clearAwardCache(); + + logger.info('Deleted award definition', { id, filename: existing._filename }); + + return { success: true, id }; +} + +/** + * Test award calculation for a user + * @param {string} id - Award ID (must exist unless awardDefinition is provided) + * @param {number} userId - User ID to test with + * @param {Object} awardDefinition - Optional award definition (for testing unsaved awards) + */ +export async function testAwardCalculation(id, userId, awardDefinition = null) { + // Get award definition - either from parameter or from cache + let award = awardDefinition; + if (!award) { + award = getAwardById(id); + if (!award) { + throw new Error(`Award "${id}" not found`); + } + } + + // Calculate progress + const progress = await calculateAwardProgress(userId, award); + + // Warn if no matches + const warnings = []; + if (progress.worked === 0 && progress.confirmed === 0) { + warnings.push('No QSOs matched the award criteria. Check filters and band/mode restrictions.'); + } + + // Get sample entities + const sampleEntities = (progress.confirmedEntities || []).slice(0, 10); + + return { + award: { + id: award.id, + name: award.name, + description: award.description, + }, + worked: progress.worked, + confirmed: progress.confirmed, + target: progress.target, + percentage: progress.percentage, + sampleEntities, + warnings, + }; +} diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index b7bea4a..fe3925a 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -53,6 +53,15 @@ function loadAwardDefinitions() { return definitions; } +/** + * Clear the cached award definitions + * Call this after creating, updating, or deleting award definitions + */ +export function clearAwardCache() { + cachedAwardDefinitions = null; + logger.info('Award cache cleared'); +} + /** * Get all available awards */ diff --git a/src/frontend/src/lib/api.js b/src/frontend/src/lib/api.js index 2464fed..4987458 100644 --- a/src/frontend/src/lib/api.js +++ b/src/frontend/src/lib/api.js @@ -130,3 +130,29 @@ export const autoSyncAPI = { getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'), }; + +// Awards Admin API +export const awardsAdminAPI = { + getAll: () => apiRequest('/admin/awards'), + + getById: (id) => apiRequest(`/admin/awards/${id}`), + + create: (data) => apiRequest('/admin/awards', { + method: 'POST', + body: JSON.stringify(data), + }), + + update: (id, data) => apiRequest(`/admin/awards/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + + delete: (id) => apiRequest(`/admin/awards/${id}`, { + method: 'DELETE', + }), + + test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, { + method: 'POST', + body: JSON.stringify({ userId, awardDefinition }), + }), +}; diff --git a/src/frontend/src/routes/admin/+page.svelte b/src/frontend/src/routes/admin/+page.svelte index 06b7fb1..5f6b33d 100644 --- a/src/frontend/src/routes/admin/+page.svelte +++ b/src/frontend/src/routes/admin/+page.svelte @@ -12,7 +12,7 @@ let impersonationStatus = null; // UI state - let selectedTab = 'overview'; // 'overview', 'users', 'actions' + let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions' let showImpersonationModal = false; let showDeleteUserModal = false; let showRoleChangeModal = false; @@ -226,6 +226,12 @@ > Users + + + + + + {#if error} +
{error}
+ {/if} + + {#if validationErrors.length > 0} +
+

Please fix the following errors:

+ +
+ {/if} + + {#if validationWarnings.length > 0} +
+

Warnings:

+ +
+ {/if} + +
+ + + +
+ +
+ {#if activeTab === 'basic'} +
+
+ + + ID cannot be changed +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Category for grouping awards (e.g., dxcc, darc, vucc, was, special) +
+
+ {/if} + + {#if activeTab === 'modeGroups'} +
+

Mode Groups

+

Define mode groups for filtering in the award detail view. This is optional.

+ + {#if Object.keys(formData.modeGroups || {}).length > 0} +
+ {#each Object.entries(formData.modeGroups || {}) as [name, modes]} +
+
+ {name} + +
+
+ {modes.join(', ')} +
+
+ {/each} +
+ {:else} +

No mode groups defined yet.

+ {/if} + +
+

Add Mode Group

+
+ + +
+
+ +
+ {#each ALL_MODES as mode} + + {/each} +
+
+ +
+
+ {/if} + + {#if activeTab === 'rules'} +
+

Award Rules

+ +
+ + +
+ + {#if formData.rules.type === 'entity'} +
+

Entity Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ + + Field to display as entity name (defaults to entity value) +
+ +
+ +
+ +
+ +
+ {#each ALL_BANDS as band} + + {/each} +
+ Leave empty to allow all bands +
+
+ {:else if formData.rules.type === 'dok'} +
+

DOK Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ DOK rules count unique (DOK, band, mode) combinations with DCL confirmation. +
+
+ {:else if formData.rules.type === 'points'} +
+

Points Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ +
+ {#if formData.rules.stations && formData.rules.stations.length > 0} + {#each formData.rules.stations as station, i} +
+ + + +
+ {/each} + {:else} +

No stations defined.

+ {/if} + +
+
+
+ {:else if formData.rules.type === 'filtered'} +
+

Filtered Rule Configuration

+ +
+ Filtered rules use a base rule with additional filter conditions. +
+ +
+ + +
+ + {#if formData.rules.baseRule?.type === 'entity'} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+ {:else if formData.rules.type === 'counter'} +
+

Counter Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ {/if} + + +
+

Filters

+

Add optional filters to restrict which QSOs count toward this award.

+ +
+
+ {/if} +
+ + + {#if showTestModal && awardId} + showTestModal = false} + /> + {/if} +{/if} + + diff --git a/src/frontend/src/routes/admin/awards/components/FilterBuilder.svelte b/src/frontend/src/routes/admin/awards/components/FilterBuilder.svelte new file mode 100644 index 0000000..8c5d15d --- /dev/null +++ b/src/frontend/src/routes/admin/awards/components/FilterBuilder.svelte @@ -0,0 +1,483 @@ + + +
+ {#if !filters || !filters.filters || filters.filters.length === 0} +
+

No filters defined. All QSOs will be evaluated.

+ +
+ {:else} +
+
+

Filters

+
+ + +
+
+ +
+ {#each filters.filters as filter, index} +
+ {#if isFilterGroup(filter)} + +
+
+ Group ({filter.operator}) + +
+ updateFilter(index, nested)} /> +
+ {:else} + +
+ + + + +
+ {#if getInputType(filter.field, filter.operator) === 'band'} + + {:else if getInputType(filter.field, filter.operator) === 'band-multi'} +
+ {#each BAND_OPTIONS as band} + + {/each} +
+ {:else if getInputType(filter.field, filter.operator) === 'mode'} + + {:else if getInputType(filter.field, filter.operator) === 'mode-multi'} +
+ {#each MODE_OPTIONS as mode} + + {/each} +
+ {:else if getInputType(filter.field, filter.operator) === 'boolean'} + + {:else if getInputType(filter.field, filter.operator) === 'text-array'} + { + const values = e.target.value.split(',').map(v => v.trim()).filter(v => v); + updateFilter(index, 'value', values); + }} + /> + {:else} + updateFilter(index, 'value', filter.value)} + /> + {/if} +
+ + +
+ {/if} +
+ {/each} +
+ +
+ + + +
+
+ {/if} +
+ + diff --git a/src/frontend/src/routes/admin/awards/components/TestAwardModal.svelte b/src/frontend/src/routes/admin/awards/components/TestAwardModal.svelte new file mode 100644 index 0000000..76a02a0 --- /dev/null +++ b/src/frontend/src/routes/admin/awards/components/TestAwardModal.svelte @@ -0,0 +1,836 @@ + + +{#if logicValidation || testResult || testError} + +{/if} + + diff --git a/src/frontend/src/routes/admin/awards/create/+page.svelte b/src/frontend/src/routes/admin/awards/create/+page.svelte new file mode 100644 index 0000000..1ba3d11 --- /dev/null +++ b/src/frontend/src/routes/admin/awards/create/+page.svelte @@ -0,0 +1,1187 @@ + + +{#if loading} +
Loading award definition...
+{:else} +
+
+

{isEdit ? 'Edit Award' : 'Create New Award'}

+
+ + +
+
+ + {#if error} +
{error}
+ {/if} + + {#if validationErrors.length > 0} +
+

Please fix the following errors:

+
    + {#each validationErrors as err} +
  • {err}
  • + {/each} +
+
+ {/if} + + {#if validationWarnings.length > 0} +
+

Warnings:

+
    + {#each validationWarnings as warn} +
  • {warn}
  • + {/each} +
+
+ {/if} + +
+ + + +
+ +
+ {#if activeTab === 'basic'} +
+
+ + + Unique identifier for the award (lowercase letters, numbers, hyphens only) +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Category for grouping awards (e.g., dxcc, darc, vucc, was, special) +
+
+ {/if} + + {#if activeTab === 'modeGroups'} +
+

Mode Groups

+

Define mode groups for filtering in the award detail view. This is optional.

+ + {#if Object.keys(formData.modeGroups || {}).length > 0} +
+ {#each Object.entries(formData.modeGroups || {}) as [name, modes]} +
+
+ {name} + +
+
+ {modes.join(', ')} +
+
+ {/each} +
+ {:else} +

No mode groups defined yet.

+ {/if} + +
+

Add Mode Group

+
+ + +
+
+ +
+ {#each ALL_MODES as mode} + + {/each} +
+
+ +
+
+ {/if} + + {#if activeTab === 'rules'} +
+

Award Rules

+ +
+ + +
+ + {#if formData.rules.type === 'entity'} +
+

Entity Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ + + Field to display as entity name (defaults to entity value) +
+ +
+ +
+ +
+ +
+ {#each ALL_BANDS as band} + + {/each} +
+ Leave empty to allow all bands +
+
+ {:else if formData.rules.type === 'dok'} +
+

DOK Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ DOK rules count unique (DOK, band, mode) combinations with DCL confirmation. +
+
+ {:else if formData.rules.type === 'points'} +
+

Points Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ +
+ {#if formData.rules.stations && formData.rules.stations.length > 0} + {#each formData.rules.stations as station, i} +
+ + + +
+ {/each} + {:else} +

No stations defined.

+ {/if} + +
+
+
+ {:else if formData.rules.type === 'filtered'} +
+

Filtered Rule Configuration

+ +
+ Filtered rules use a base rule with additional filter conditions. +
+ +
+ + +
+ + {#if formData.rules.baseRule?.type === 'entity'} +
+
+ + +
+ +
+ + +
+
+ {/if} +
+ {:else if formData.rules.type === 'counter'} +
+

Counter Rule Configuration

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ {/if} + + +
+

Filters

+

Add optional filters to restrict which QSOs count toward this award.

+ +
+
+ {/if} +
+
+ + {#if showTestModal && (formData.id || awardId)} + showTestModal = false} + /> + {/if} +{/if} + +