Compare commits
3 Commits
b9b6afedb8
...
24e0e3bfdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
24e0e3bfdb
|
|||
|
36453c8922
|
|||
|
bd89ea0855
|
25
award-definitions/qo100grids.json
Normal file
25
award-definitions/qo100grids.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "sat-rs44",
|
"id": "sat-rs44",
|
||||||
"name": "RS-44 Satellite",
|
"name": "44 on RS-44",
|
||||||
"description": "Work 44 QSOs on satellite RS-44",
|
"description": "Work 44 QSOs on satellite RS-44",
|
||||||
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
|
"caption": "Make 44 unique QSOs via the RS-44 satellite. Each QSO with a different callsign counts toward the total.",
|
||||||
"category": "custom",
|
"category": "satellite",
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "counter",
|
"type": "counter",
|
||||||
"target": 44,
|
"target": 44,
|
||||||
@@ -19,5 +19,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"modeGroups": {}
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,14 @@ import {
|
|||||||
getAwardProgressDetails,
|
getAwardProgressDetails,
|
||||||
getAwardEntityBreakdown,
|
getAwardEntityBreakdown,
|
||||||
} from './services/awards.service.js';
|
} from './services/awards.service.js';
|
||||||
|
import {
|
||||||
|
getAllAwardDefinitions,
|
||||||
|
getAwardDefinition,
|
||||||
|
createAwardDefinition,
|
||||||
|
updateAwardDefinition,
|
||||||
|
deleteAwardDefinition,
|
||||||
|
testAwardCalculation,
|
||||||
|
} from './services/awards-admin.service.js';
|
||||||
import {
|
import {
|
||||||
getAutoSyncSettings,
|
getAutoSyncSettings,
|
||||||
updateAutoSyncSettings,
|
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
|
* AUTO-SYNC SETTINGS ROUTES
|
||||||
|
|||||||
547
src/backend/services/awards-admin.service.js
Normal file
547
src/backend/services/awards-admin.service.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,8 +30,7 @@ function loadAwardDefinitions() {
|
|||||||
try {
|
try {
|
||||||
// Auto-discover all JSON files in the award-definitions directory
|
// Auto-discover all JSON files in the award-definitions directory
|
||||||
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
||||||
.filter(f => f.endsWith('.json'))
|
.filter(f => f.endsWith('.json'));
|
||||||
.sort();
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
@@ -47,12 +46,47 @@ function loadAwardDefinitions() {
|
|||||||
logger.error('Error loading award definitions', { error: error.message });
|
logger.error('Error loading award definitions', { error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort by award name with numeric prefixes in numerical order
|
||||||
|
definitions.sort((a, b) => {
|
||||||
|
const nameA = a.name || '';
|
||||||
|
const nameB = b.name || '';
|
||||||
|
|
||||||
|
// Extract leading numbers if present
|
||||||
|
const matchA = nameA.match(/^(\d+)/);
|
||||||
|
const matchB = nameB.match(/^(\d+)/);
|
||||||
|
|
||||||
|
// If both start with numbers, compare numerically first
|
||||||
|
if (matchA && matchB) {
|
||||||
|
const numA = parseInt(matchA[1], 10);
|
||||||
|
const numB = parseInt(matchB[1], 10);
|
||||||
|
if (numA !== numB) {
|
||||||
|
return numA - numB;
|
||||||
|
}
|
||||||
|
// If numbers are equal, fall through to alphabetical
|
||||||
|
}
|
||||||
|
// If one starts with a number, it comes first
|
||||||
|
else if (matchA) return -1;
|
||||||
|
else if (matchB) return 1;
|
||||||
|
|
||||||
|
// Otherwise, alphabetical comparison (case-insensitive)
|
||||||
|
return nameA.toLowerCase().localeCompare(nameB.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
// Cache the definitions for future calls
|
// Cache the definitions for future calls
|
||||||
cachedAwardDefinitions = definitions;
|
cachedAwardDefinitions = definitions;
|
||||||
|
|
||||||
return definitions;
|
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
|
* Get all available awards
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -130,3 +130,29 @@ export const autoSyncAPI = {
|
|||||||
|
|
||||||
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
|
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 }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
let impersonationStatus = null;
|
let impersonationStatus = null;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
let selectedTab = 'overview'; // 'overview', 'users', 'actions'
|
let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
|
||||||
let showImpersonationModal = false;
|
let showImpersonationModal = false;
|
||||||
let showDeleteUserModal = false;
|
let showDeleteUserModal = false;
|
||||||
let showRoleChangeModal = false;
|
let showRoleChangeModal = false;
|
||||||
@@ -226,6 +226,12 @@
|
|||||||
>
|
>
|
||||||
Users
|
Users
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab {selectedTab === 'awards' ? 'active' : ''}"
|
||||||
|
on:click={() => selectedTab = 'awards'}
|
||||||
|
>
|
||||||
|
Awards
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
||||||
on:click={() => selectedTab = 'actions'}
|
on:click={() => selectedTab = 'actions'}
|
||||||
@@ -382,6 +388,30 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Awards Tab -->
|
||||||
|
{#if selectedTab === 'awards'}
|
||||||
|
<div class="tab-content">
|
||||||
|
<h2>Award Definitions</h2>
|
||||||
|
<p class="help-text">Manage award definitions. Create, edit, and delete awards.</p>
|
||||||
|
|
||||||
|
<div class="awards-quick-actions">
|
||||||
|
<a href="/admin/awards" class="btn btn-primary">Manage Awards</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awards-info">
|
||||||
|
<h3>Award Management</h3>
|
||||||
|
<p>From the Awards management page, you can:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Create</strong> new award definitions</li>
|
||||||
|
<li><strong>Edit</strong> existing award definitions</li>
|
||||||
|
<li><strong>Delete</strong> awards</li>
|
||||||
|
<li><strong>Test</strong> award calculations with sample user data</li>
|
||||||
|
</ul>
|
||||||
|
<p>All award definitions are stored as JSON files in the <code>award-definitions/</code> directory.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Actions Tab -->
|
<!-- Actions Tab -->
|
||||||
{#if selectedTab === 'actions'}
|
{#if selectedTab === 'actions'}
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -919,6 +949,50 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-quick-actions {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info ul {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-info code {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.users-header {
|
.users-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
379
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
379
src/frontend/src/routes/admin/awards/+page.svelte
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { auth } from '$lib/stores.js';
|
||||||
|
import { awardsAdminAPI } from '$lib/api.js';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
let loading = true;
|
||||||
|
let error = null;
|
||||||
|
let awards = [];
|
||||||
|
let searchQuery = '';
|
||||||
|
let categoryFilter = 'all';
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!$auth.user) {
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$auth.user.isAdmin) {
|
||||||
|
error = 'Admin access required';
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAwards();
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAwards() {
|
||||||
|
try {
|
||||||
|
const data = await awardsAdminAPI.getAll();
|
||||||
|
awards = data.awards || [];
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
const award = awards.find(a => a.id === id);
|
||||||
|
if (!award) return;
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to delete award "${award.name}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading = true;
|
||||||
|
await awardsAdminAPI.delete(id);
|
||||||
|
await loadAwards();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to delete award: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleTypeDisplayName(ruleType) {
|
||||||
|
const names = {
|
||||||
|
'entity': 'Entity',
|
||||||
|
'dok': 'DOK',
|
||||||
|
'points': 'Points',
|
||||||
|
'filtered': 'Filtered',
|
||||||
|
'counter': 'Counter'
|
||||||
|
};
|
||||||
|
return names[ruleType] || ruleType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCategoryColor(category) {
|
||||||
|
const colors = {
|
||||||
|
'dxcc': 'purple',
|
||||||
|
'darc': 'orange',
|
||||||
|
'vucc': 'blue',
|
||||||
|
'was': 'green',
|
||||||
|
'special': 'red',
|
||||||
|
};
|
||||||
|
return colors[category] || 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filteredAwards = awards.filter(award => {
|
||||||
|
const matchesSearch = !searchQuery ||
|
||||||
|
award.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
award.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
award.category.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
|
const matchesCategory = categoryFilter === 'all' || award.category === categoryFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesCategory;
|
||||||
|
});
|
||||||
|
|
||||||
|
$: categories = [...new Set(awards.map(a => a.category))].sort();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading && awards.length === 0}
|
||||||
|
<div class="loading">Loading award definitions...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="awards-admin">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Award Definitions</h1>
|
||||||
|
<a href="/admin/awards/create" class="btn btn-primary">Create New Award</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search by name, ID, or category..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
<select class="category-filter" bind:value={categoryFilter}>
|
||||||
|
<option value="all">All Categories</option>
|
||||||
|
{#each categories as category}
|
||||||
|
<option value={category}>{category}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="awards-table-container">
|
||||||
|
<table class="awards-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Rule Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each filteredAwards as award}
|
||||||
|
<tr>
|
||||||
|
<td class="id-cell">{award.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="name-cell">
|
||||||
|
<strong>{award.name}</strong>
|
||||||
|
<small>{award.description}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="category-badge {getCategoryColor(award.category)}">
|
||||||
|
{award.category}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{getRuleTypeDisplayName(award.rules.type)}</td>
|
||||||
|
<td>{award.rules.target || '-'}</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<a href="/admin/awards/{award.id}" class="action-btn edit-btn">Edit</a>
|
||||||
|
<a href="/awards/{award.id}" target="_blank" class="action-btn view-btn">View</a>
|
||||||
|
<button
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
on:click={() => handleDelete(award.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="count">Showing {filteredAwards.length} award(s)</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.awards-admin {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background-color: #fee;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #c00;
|
||||||
|
margin: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.category-filter {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-filter {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table th,
|
||||||
|
.awards-table td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table th {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.awards-table tr:hover {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-cell small {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-badge.purple { background-color: #9b59b6; color: white; }
|
||||||
|
.category-badge.orange { background-color: #e67e22; color: white; }
|
||||||
|
.category-badge.blue { background-color: #3498db; color: white; }
|
||||||
|
.category-badge.green { background-color: #27ae60; color: white; }
|
||||||
|
.category-badge.red { background-color: #e74c3c; color: white; }
|
||||||
|
.category-badge.gray { background-color: #95a5a6; color: white; }
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn:hover {
|
||||||
|
background-color: #219a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.awards-admin {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1195
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
1195
src/frontend/src/routes/admin/awards/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
export let filters = null;
|
||||||
|
export let onChange = () => {};
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const ALL_FIELDS = [
|
||||||
|
{ value: 'band', label: 'Band' },
|
||||||
|
{ value: 'mode', label: 'Mode' },
|
||||||
|
{ value: 'callsign', label: 'Callsign' },
|
||||||
|
{ value: 'entity', label: 'Entity (Country)' },
|
||||||
|
{ value: 'entityId', label: 'Entity ID' },
|
||||||
|
{ value: 'state', label: 'State' },
|
||||||
|
{ value: 'grid', label: 'Grid Square' },
|
||||||
|
{ value: 'satName', label: 'Satellite Name' },
|
||||||
|
{ value: 'satellite', label: 'Is Satellite QSO' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPERATORS = [
|
||||||
|
{ value: 'eq', label: 'Equals', needsArray: false },
|
||||||
|
{ value: 'ne', label: 'Not Equals', needsArray: false },
|
||||||
|
{ value: 'in', label: 'In', needsArray: true },
|
||||||
|
{ value: 'nin', label: 'Not In', needsArray: true },
|
||||||
|
{ value: 'contains', label: 'Contains', needsArray: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BAND_OPTIONS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '2m', '70cm', '23cm', '13cm', '9cm', '6cm', '3cm'];
|
||||||
|
const MODE_OPTIONS = ['CW', 'SSB', 'AM', 'FM', 'RTTY', 'PSK31', 'FT8', 'FT4', 'JT65', 'JT9', 'MFSK', 'Q65', 'JS8', 'FSK441', 'ISCAT', 'JT6M', 'MSK144'];
|
||||||
|
|
||||||
|
// Add a new filter
|
||||||
|
function addFilter() {
|
||||||
|
if (!filters) {
|
||||||
|
filters = { operator: 'AND', filters: [] };
|
||||||
|
}
|
||||||
|
if (!filters.filters) {
|
||||||
|
filters.filters = [];
|
||||||
|
}
|
||||||
|
filters.filters.push({
|
||||||
|
field: 'band',
|
||||||
|
operator: 'eq',
|
||||||
|
value: ''
|
||||||
|
});
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a nested filter group
|
||||||
|
function addFilterGroup() {
|
||||||
|
if (!filters) {
|
||||||
|
filters = { operator: 'AND', filters: [] };
|
||||||
|
}
|
||||||
|
if (!filters.filters) {
|
||||||
|
filters.filters = [];
|
||||||
|
}
|
||||||
|
filters.filters.push({
|
||||||
|
operator: 'AND',
|
||||||
|
filters: []
|
||||||
|
});
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove a filter at index
|
||||||
|
function removeFilter(index) {
|
||||||
|
if (filters && filters.filters) {
|
||||||
|
filters.filters.splice(index, 1);
|
||||||
|
// If no filters left, set to null
|
||||||
|
if (filters.filters.length === 0) {
|
||||||
|
filters = null;
|
||||||
|
}
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a filter at index
|
||||||
|
function updateFilter(index, key, value) {
|
||||||
|
if (filters && filters.filters) {
|
||||||
|
filters.filters[index][key] = value;
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter operator (AND/OR)
|
||||||
|
function updateOperator(operator) {
|
||||||
|
if (filters) {
|
||||||
|
filters.operator = operator;
|
||||||
|
updateFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get input type based on field and operator
|
||||||
|
function getInputType(field, operator) {
|
||||||
|
const opConfig = OPERATORS.find(o => o.value === operator);
|
||||||
|
const needsArray = opConfig?.needsArray || false;
|
||||||
|
|
||||||
|
if (field === 'band' && needsArray) return 'band-multi';
|
||||||
|
if (field === 'band') return 'band';
|
||||||
|
if (field === 'mode' && needsArray) return 'mode-multi';
|
||||||
|
if (field === 'mode') return 'mode';
|
||||||
|
if (field === 'satellite') return 'boolean';
|
||||||
|
if (needsArray) return 'text-array';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
function updateFilters() {
|
||||||
|
// Deep clone to avoid reactivity issues
|
||||||
|
const cloned = filters ? JSON.parse(JSON.stringify(filters)) : null;
|
||||||
|
onChange(cloned);
|
||||||
|
dispatch('change', cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a filter is a group (has nested filters)
|
||||||
|
function isFilterGroup(filter) {
|
||||||
|
return filter && typeof filter === 'object' && filter.filters !== undefined;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="filter-builder">
|
||||||
|
{#if !filters || !filters.filters || filters.filters.length === 0}
|
||||||
|
<div class="no-filters">
|
||||||
|
<p>No filters defined. All QSOs will be evaluated.</p>
|
||||||
|
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="filter-group">
|
||||||
|
<div class="filter-group-header">
|
||||||
|
<h4>Filters</h4>
|
||||||
|
<div class="operator-selector">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={filters.operator}
|
||||||
|
value="AND"
|
||||||
|
on:change={() => updateOperator('AND')}
|
||||||
|
/>
|
||||||
|
AND
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
bind:group={filters.operator}
|
||||||
|
value="OR"
|
||||||
|
on:change={() => updateOperator('OR')}
|
||||||
|
/>
|
||||||
|
OR
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-list">
|
||||||
|
{#each filters.filters as filter, index}
|
||||||
|
<div class="filter-item">
|
||||||
|
{#if isFilterGroup(filter)}
|
||||||
|
<!-- Nested filter group -->
|
||||||
|
<div class="nested-filter-group">
|
||||||
|
<div class="nested-header">
|
||||||
|
<span class="group-label">Group ({filter.operator})</span>
|
||||||
|
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
<svelte:self filters={filter} onChange={(nested) => updateFilter(index, nested)} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Single filter -->
|
||||||
|
<div class="single-filter">
|
||||||
|
<select
|
||||||
|
class="field-select"
|
||||||
|
bind:value={filter.field}
|
||||||
|
on:change={() => updateFilter(index, 'field', filter.field)}
|
||||||
|
>
|
||||||
|
{#each ALL_FIELDS as field}
|
||||||
|
<option value={field.value}>{field.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class="operator-select"
|
||||||
|
bind:value={filter.operator}
|
||||||
|
on:change={() => updateFilter(index, 'operator', filter.operator)}
|
||||||
|
>
|
||||||
|
{#each OPERATORS as op}
|
||||||
|
<option value={op.value}>{op.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="filter-value">
|
||||||
|
{#if getInputType(filter.field, filter.operator) === 'band'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="">Select band</option>
|
||||||
|
{#each BAND_OPTIONS as band}
|
||||||
|
<option value={band}>{band}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'band-multi'}
|
||||||
|
<div class="multi-select">
|
||||||
|
{#each BAND_OPTIONS as band}
|
||||||
|
<label class="checkbox-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Array.isArray(filter.value) && filter.value.includes(band)}
|
||||||
|
on:change={(e) => {
|
||||||
|
if (!Array.isArray(filter.value)) filter.value = [];
|
||||||
|
if (e.target.checked) {
|
||||||
|
filter.value = [...filter.value, band];
|
||||||
|
} else {
|
||||||
|
filter.value = filter.value.filter(v => v !== band);
|
||||||
|
}
|
||||||
|
updateFilter(index, 'value', filter.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{band}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'mode'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="">Select mode</option>
|
||||||
|
{#each MODE_OPTIONS as mode}
|
||||||
|
<option value={mode}>{mode}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'mode-multi'}
|
||||||
|
<div class="multi-select">
|
||||||
|
{#each MODE_OPTIONS as mode}
|
||||||
|
<label class="checkbox-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Array.isArray(filter.value) && filter.value.includes(mode)}
|
||||||
|
on:change={(e) => {
|
||||||
|
if (!Array.isArray(filter.value)) filter.value = [];
|
||||||
|
if (e.target.checked) {
|
||||||
|
filter.value = [...filter.value, mode];
|
||||||
|
} else {
|
||||||
|
filter.value = filter.value.filter(v => v !== mode);
|
||||||
|
}
|
||||||
|
updateFilter(index, 'value', filter.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{mode}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'boolean'}
|
||||||
|
<select bind:value={filter.value} on:change={() => updateFilter(index, 'value', filter.value)}>
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
<option value="false">No</option>
|
||||||
|
</select>
|
||||||
|
{:else if getInputType(filter.field, filter.operator) === 'text-array'}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="comma-separated values"
|
||||||
|
value={Array.isArray(filter.value) ? filter.value.join(', ') : filter.value}
|
||||||
|
on:change={(e) => {
|
||||||
|
const values = e.target.value.split(',').map(v => v.trim()).filter(v => v);
|
||||||
|
updateFilter(index, 'value', values);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value"
|
||||||
|
bind:value={filter.value}
|
||||||
|
on:change={() => updateFilter(index, 'value', filter.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-remove" on:click={() => removeFilter(index)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button class="btn btn-secondary" on:click={addFilter}>Add Filter</button>
|
||||||
|
<button class="btn btn-secondary" on:click={addFilterGroup}>Add Filter Group</button>
|
||||||
|
<button class="btn btn-danger" on:click={() => { filters = null; updateFilters(); }}>Clear All Filters</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.filter-builder {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-filters {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-selector label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-item {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.single-filter select,
|
||||||
|
.single-filter input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select {
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operator-select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-value select,
|
||||||
|
.filter-value input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested-filter-group {
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nested-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-option input {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.single-filter {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-select,
|
||||||
|
.operator-select,
|
||||||
|
.filter-value {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-actions button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { awardsAdminAPI, adminAPI } from '$lib/api.js';
|
||||||
|
|
||||||
|
export let awardId = null;
|
||||||
|
export let awardDefinition = null;
|
||||||
|
export let onClose = () => {};
|
||||||
|
|
||||||
|
let loading = false;
|
||||||
|
let testResult = null;
|
||||||
|
let testError = null;
|
||||||
|
let users = [];
|
||||||
|
let selectedUserId = null;
|
||||||
|
|
||||||
|
// Extended validation results
|
||||||
|
let logicValidation = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadUsers();
|
||||||
|
if (awardDefinition) {
|
||||||
|
performLogicValidation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const data = await adminAPI.getUsers();
|
||||||
|
users = data.users || [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load users:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
if (!awardId) return;
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
testResult = null;
|
||||||
|
testError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pass awardDefinition for unsaved awards (testing during create/edit)
|
||||||
|
const data = await awardsAdminAPI.test(awardId, selectedUserId, awardDefinition);
|
||||||
|
testResult = data;
|
||||||
|
} catch (err) {
|
||||||
|
testError = err.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform deep logic validation on award definition
|
||||||
|
function performLogicValidation() {
|
||||||
|
if (!awardDefinition) return;
|
||||||
|
|
||||||
|
const issues = {
|
||||||
|
errors: [],
|
||||||
|
warnings: [],
|
||||||
|
info: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const rules = awardDefinition.rules;
|
||||||
|
|
||||||
|
// 1. Check for impossible filter combinations
|
||||||
|
if (rules.filters) {
|
||||||
|
const impossibleFilterCombos = checkImpossibleFilters(rules.filters, rules);
|
||||||
|
issues.errors.push(...impossibleFilterCombos.errors);
|
||||||
|
issues.warnings.push(...impossibleFilterCombos.warnings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for redundancy
|
||||||
|
const redundancies = checkRedundancies(rules);
|
||||||
|
issues.warnings.push(...redundancies.warnings);
|
||||||
|
issues.info.push(...redundancies.info);
|
||||||
|
|
||||||
|
// 3. Check for logical contradictions
|
||||||
|
const contradictions = checkContradictions(rules);
|
||||||
|
issues.errors.push(...contradictions.errors);
|
||||||
|
issues.warnings.push(...contradictions.warnings);
|
||||||
|
|
||||||
|
// 4. Check for edge cases that might cause issues
|
||||||
|
const edgeCases = checkEdgeCases(rules);
|
||||||
|
issues.info.push(...edgeCases);
|
||||||
|
|
||||||
|
// 5. Provide helpful suggestions
|
||||||
|
const suggestions = provideSuggestions(rules);
|
||||||
|
issues.info.push(...suggestions);
|
||||||
|
|
||||||
|
logicValidation = issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for impossible filter combinations
|
||||||
|
function checkImpossibleFilters(filters, rules) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
function analyze(filterNode, depth = 0) {
|
||||||
|
if (!filterNode || !filterNode.filters) return;
|
||||||
|
|
||||||
|
// Group filters by field to check for contradictions
|
||||||
|
const fieldFilters = {};
|
||||||
|
for (const f of filterNode.filters) {
|
||||||
|
if (f.field) {
|
||||||
|
if (!fieldFilters[f.field]) fieldFilters[f.field] = [];
|
||||||
|
fieldFilters[f.field].push(f);
|
||||||
|
} else if (f.filters) {
|
||||||
|
analyze(f, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contradictions in AND groups
|
||||||
|
if (filterNode.operator === 'AND') {
|
||||||
|
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
|
||||||
|
// Check for direct contradictions: field=X AND field=Y
|
||||||
|
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
|
||||||
|
const neFilters = fieldFiltersList.filter(f => f.operator === 'ne');
|
||||||
|
|
||||||
|
for (const eq1 of eqFilters) {
|
||||||
|
for (const eq2 of eqFilters) {
|
||||||
|
if (eq1 !== eq2 && eq1.value !== eq2.value) {
|
||||||
|
errors.push(`Impossible filter: ${field} cannot be both "${eq1.value}" AND "${eq2.value}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ne of neFilters) {
|
||||||
|
if (eq1.value === ne.value) {
|
||||||
|
errors.push(`Impossible filter: ${field} cannot be "${eq1.value}" AND not "${ne.value}" at the same time`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for in/nin contradictions
|
||||||
|
const inFilters = fieldFiltersList.filter(f => f.operator === 'in');
|
||||||
|
const ninFilters = fieldFiltersList.filter(f => f.operator === 'nin');
|
||||||
|
|
||||||
|
for (const inF of inFilters) {
|
||||||
|
if (Array.isArray(inF.value)) {
|
||||||
|
for (const ninF of ninFilters) {
|
||||||
|
if (Array.isArray(ninF.value)) {
|
||||||
|
const overlap = inF.value.filter(v => ninF.value.includes(v));
|
||||||
|
if (overlap.length > 0 && overlap.length === inF.value.length) {
|
||||||
|
errors.push(`Impossible filter: ${field} must be in ${inF.value.join(', ')} AND not in ${overlap.join(', ')}`);
|
||||||
|
} else if (overlap.length > 0) {
|
||||||
|
warnings.push(`Suspicious filter: ${field} filter has overlapping values: ${overlap.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for redundant OR groups (field=X OR field=X)
|
||||||
|
if (filterNode.operator === 'OR') {
|
||||||
|
for (const [field, fieldFiltersList] of Object.entries(fieldFilters)) {
|
||||||
|
const eqFilters = fieldFiltersList.filter(f => f.operator === 'eq');
|
||||||
|
|
||||||
|
for (let i = 0; i < eqFilters.length; i++) {
|
||||||
|
for (let j = i + 1; j < eqFilters.length; j++) {
|
||||||
|
if (eqFilters[i].value === eqFilters[j].value) {
|
||||||
|
warnings.push(`Redundant filter: ${field}="${eqFilters[i].value}" appears multiple times in OR group`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
analyze(filters);
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for redundancies in the definition
|
||||||
|
function checkRedundancies(rules) {
|
||||||
|
const warnings = [];
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
// Check if satellite_only is redundant when filters already check for satellite
|
||||||
|
if (rules.satellite_only && rules.filters) {
|
||||||
|
const satFilter = findSatelliteFilter(rules.filters);
|
||||||
|
if (satFilter && satFilter.operator === 'eq' && satFilter.value === true) {
|
||||||
|
info.push('satellite_only=true is set, but filters already check for satellite QSOs. The filter is redundant.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if allowed_bands makes filters redundant
|
||||||
|
if (rules.allowed_bands && rules.allowed_bands.length > 0 && rules.filters) {
|
||||||
|
const bandFilters = extractBandFilters(rules.filters);
|
||||||
|
for (const bf of bandFilters) {
|
||||||
|
if (bf.operator === 'in' && Array.isArray(bf.value)) {
|
||||||
|
const allCovered = bf.value.every(b => rules.allowed_bands.includes(b));
|
||||||
|
if (allCovered) {
|
||||||
|
info.push(`allowed_bands already includes all bands in the filter. Consider removing the filter.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if displayField matches the default for the entity type
|
||||||
|
if (rules.entityType && rules.displayField) {
|
||||||
|
const defaults = {
|
||||||
|
'dxcc': 'entity',
|
||||||
|
'state': 'state',
|
||||||
|
'grid': 'grid',
|
||||||
|
'callsign': 'callsign'
|
||||||
|
};
|
||||||
|
if (defaults[rules.entityType] === rules.displayField) {
|
||||||
|
info.push(`displayField="${rules.displayField}" is the default for entityType="${rules.entityType}". It can be omitted.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { warnings, info };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for logical contradictions
|
||||||
|
function checkContradictions(rules) {
|
||||||
|
const errors = [];
|
||||||
|
const warnings = [];
|
||||||
|
|
||||||
|
// Check satellite_only with HF-only allowed_bands
|
||||||
|
if (rules.satellite_only && rules.allowed_bands) {
|
||||||
|
const hfBands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m'];
|
||||||
|
const hasHfOnly = rules.allowed_bands.length > 0 &&
|
||||||
|
rules.allowed_bands.every(b => hfBands.includes(b));
|
||||||
|
|
||||||
|
if (hasHfOnly) {
|
||||||
|
warnings.push('satellite_only is set but allowed_bands only includes HF bands. Satellite work typically uses VHF/UHF bands.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For DOK rules, verify confirmation type
|
||||||
|
if (rules.type === 'dok' && rules.confirmationType && rules.confirmationType !== 'dcl') {
|
||||||
|
warnings.push('DOK awards typically require DCL confirmation (confirmationType="dcl").');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for impossible targets
|
||||||
|
if (rules.target) {
|
||||||
|
if (rules.type === 'entity' && rules.entityType === 'dxcc' && rules.target > 340) {
|
||||||
|
warnings.push(`Target (${rules.target}) exceeds the total number of DXCC entities (~340).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'dok' && rules.target > 700) {
|
||||||
|
info.push(`Target (${rules.target}) is high. There are ~700 DOKs in Germany.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { errors, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for edge cases
|
||||||
|
function checkEdgeCases(rules) {
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
if (rules.filters) {
|
||||||
|
const filterCount = countFilters(rules.filters);
|
||||||
|
if (filterCount > 10) {
|
||||||
|
info.push(`Complex filter structure (${filterCount} filters). Consider simplifying for better performance.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.modeGroups) {
|
||||||
|
const totalModes = Object.values(rules.modeGroups).reduce((sum, modes) => sum + (modes?.length || 0), 0);
|
||||||
|
if (totalModes > 20) {
|
||||||
|
info.push('Many mode groups defined. Make sure users understand the grouping logic.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'points' && rules.stations) {
|
||||||
|
const totalPossiblePoints = rules.stations.reduce((sum, s) => sum + (s.points || 0), 0);
|
||||||
|
if (totalPossiblePoints < rules.target) {
|
||||||
|
info.push(`Even with all stations confirmed, max points (${totalPossiblePoints}) is less than target (${rules.target}). Award is impossible to complete.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide helpful suggestions
|
||||||
|
function provideSuggestions(rules) {
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
// Suggest common award patterns
|
||||||
|
if (rules.type === 'entity' && rules.entityType === 'dxcc' && !rules.allowed_bands) {
|
||||||
|
info.push('Consider adding allowed_bands to restrict to specific bands (e.g., HF only: ["160m", "80m", ...]).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'entity' && !rules.modeGroups && ['dxcc', 'dld'].includes(rules.entityType)) {
|
||||||
|
info.push('Consider adding modeGroups to help users filter by mode type (e.g., "Digi-Modes", "Phone-Modes").');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rules.type === 'dok' && !rules.filters) {
|
||||||
|
info.push('DOK awards can have band/mode filters via the filters property. Consider adding them for specific variations.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Find satellite-related filter
|
||||||
|
function findSatelliteFilter(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return null;
|
||||||
|
|
||||||
|
if (filters.field === 'satellite' || filters.field === 'satName') {
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
const found = findSatelliteFilter(f, depth + 1);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Extract band filters
|
||||||
|
function extractBandFilters(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return [];
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (filters.field === 'band') {
|
||||||
|
result.push(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
result.push(...extractBandFilters(f, depth + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Count total filters
|
||||||
|
function countFilters(filters, depth = 0) {
|
||||||
|
if (!filters || depth > 5) return 0;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
if (filters.filters) {
|
||||||
|
for (const f of filters.filters) {
|
||||||
|
if (f.filters) {
|
||||||
|
count += 1 + countFilters(f, depth + 1);
|
||||||
|
} else {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeverityClass(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'error': return 'severity-error';
|
||||||
|
case 'warning': return 'severity-warning';
|
||||||
|
case 'info': return 'severity-info';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if logicValidation || testResult || testError}
|
||||||
|
<div class="modal-overlay" on:click={onClose}>
|
||||||
|
<div class="modal-content large" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{testResult ? 'Test Results' : 'Award Validation'}{awardId ? `: ${awardId}` : ''}</h2>
|
||||||
|
<button class="close-btn" on:click={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Logic Validation Section -->
|
||||||
|
{#if logicValidation && (logicValidation.errors.length > 0 || logicValidation.warnings.length > 0 || logicValidation.info.length > 0)}
|
||||||
|
<div class="validation-section">
|
||||||
|
<h3>Logic Validation</h3>
|
||||||
|
|
||||||
|
{#if logicValidation.errors.length > 0}
|
||||||
|
<div class="validation-block errors">
|
||||||
|
<h4>Errors (must fix)</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.errors as err}
|
||||||
|
<li class="severity-error">{err}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.warnings.length > 0}
|
||||||
|
<div class="validation-block warnings">
|
||||||
|
<h4>Warnings</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.warnings as warn}
|
||||||
|
<li class="severity-warning">{warn}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.info.length > 0}
|
||||||
|
<div class="validation-block info">
|
||||||
|
<h4>Suggestions</h4>
|
||||||
|
<ul>
|
||||||
|
{#each logicValidation.info as info}
|
||||||
|
<li class="severity-info">{info}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if logicValidation.errors.length === 0 && logicValidation.warnings.length === 0}
|
||||||
|
<div class="validation-block success">
|
||||||
|
<p>No issues found. The award definition looks good!</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Test Configuration -->
|
||||||
|
<div class="test-config">
|
||||||
|
<h3>Test Calculation</h3>
|
||||||
|
<p class="help-text">Select a user to test the award calculation with their QSO data.</p>
|
||||||
|
|
||||||
|
<div class="user-selector">
|
||||||
|
<label for="test-user">Test with user:</label>
|
||||||
|
<select id="test-user" bind:value={selectedUserId}>
|
||||||
|
<option value="">-- Select a user --</option>
|
||||||
|
{#each users as user}
|
||||||
|
<option value={user.id}>{user.callsign} ({user.email}) - {user.qsoCount || 0} QSOs</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={runTest}
|
||||||
|
disabled={loading || !selectedUserId || !awardId}
|
||||||
|
>
|
||||||
|
{loading ? 'Testing...' : 'Run Test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Results -->
|
||||||
|
{#if testError}
|
||||||
|
<div class="test-results error">
|
||||||
|
<h4>Test Failed</h4>
|
||||||
|
<p>{testError}</p>
|
||||||
|
</div>
|
||||||
|
{:else if testResult}
|
||||||
|
<div class="test-results success">
|
||||||
|
<h4>Test Results</h4>
|
||||||
|
|
||||||
|
<div class="result-summary">
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Award:</span>
|
||||||
|
<span class="value">{testResult.award?.name || awardId}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Worked:</span>
|
||||||
|
<span class="value">{testResult.worked || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Confirmed:</span>
|
||||||
|
<span class="value confirmed">{testResult.confirmed || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Target:</span>
|
||||||
|
<span class="value">{testResult.target || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-item">
|
||||||
|
<span class="label">Progress:</span>
|
||||||
|
<span class="value progress">{testResult.percentage || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if testResult.warnings && testResult.warnings.length > 0}
|
||||||
|
<div class="result-warnings">
|
||||||
|
<h5>Warnings:</h5>
|
||||||
|
<ul>
|
||||||
|
{#each testResult.warnings as warning}
|
||||||
|
<li>{warning}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if testResult.sampleEntities && testResult.sampleEntities.length > 0}
|
||||||
|
<div class="sample-entities">
|
||||||
|
<h5>Sample Matched Entities (first {testResult.sampleEntities.length}):</h5>
|
||||||
|
<div class="entities-list">
|
||||||
|
{#each testResult.sampleEntities as entity}
|
||||||
|
<span class="entity-tag">{entity}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-matches">
|
||||||
|
<p>No entities matched. Check filters and band/mode restrictions.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" on:click={onClose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content.large {
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #888;
|
||||||
|
padding: 0;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block h4 {
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.errors {
|
||||||
|
background-color: #fee;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.warnings {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.info {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-left: 4px solid #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-error {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-warning {
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity-info {
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config {
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-config h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results.error {
|
||||||
|
background-color: #fee;
|
||||||
|
border-left: 4px solid #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results.success {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-results h4 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value.confirmed {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item .value.progress {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings h5 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-warnings ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-entities {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-entities h5 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entities-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-tag {
|
||||||
|
background-color: white;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #0d47a1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-matches {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: center;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.modal-content {
|
||||||
|
height: 100vh;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-selector {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1221
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
1221
src/frontend/src/routes/admin/awards/create/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -550,8 +550,8 @@
|
|||||||
<div class="summary">
|
<div class="summary">
|
||||||
{#if entities.length > 0 && entities[0].points !== undefined}
|
{#if entities.length > 0 && entities[0].points !== undefined}
|
||||||
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||||
{@const targetPoints = award.target}
|
{@const targetPoints = award.rules?.target}
|
||||||
{@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
|
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span class="summary-label">Total Combinations:</span>
|
<span class="summary-label">Total Combinations:</span>
|
||||||
<span class="summary-value">{filteredEntities.length}</span>
|
<span class="summary-value">{filteredEntities.length}</span>
|
||||||
@@ -564,6 +564,7 @@
|
|||||||
<span class="summary-label">Points:</span>
|
<span class="summary-label">Points:</span>
|
||||||
<span class="summary-value">{earnedPoints}</span>
|
<span class="summary-value">{earnedPoints}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if neededPoints !== null}
|
||||||
<div class="summary-card unworked">
|
<div class="summary-card unworked">
|
||||||
<span class="summary-label">Needed:</span>
|
<span class="summary-label">Needed:</span>
|
||||||
<span class="summary-value">{neededPoints}</span>
|
<span class="summary-value">{neededPoints}</span>
|
||||||
@@ -572,8 +573,10 @@
|
|||||||
<span class="summary-label">Target:</span>
|
<span class="summary-label">Target:</span>
|
||||||
<span class="summary-value">{targetPoints}</span>
|
<span class="summary-value">{targetPoints}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
{@const neededCount = award.target ? Math.max(0, award.target - uniqueEntityProgress.worked) : uniqueEntityProgress.total - uniqueEntityProgress.worked}
|
{@const targetCount = award.rules?.target}
|
||||||
|
{@const neededCount = targetCount !== undefined ? Math.max(0, targetCount - uniqueEntityProgress.worked) : null}
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
<span class="summary-label">Total:</span>
|
<span class="summary-label">Total:</span>
|
||||||
<span class="summary-value">{uniqueEntityProgress.total}</span>
|
<span class="summary-value">{uniqueEntityProgress.total}</span>
|
||||||
@@ -586,10 +589,16 @@
|
|||||||
<span class="summary-label">Worked:</span>
|
<span class="summary-label">Worked:</span>
|
||||||
<span class="summary-value">{uniqueEntityProgress.worked}</span>
|
<span class="summary-value">{uniqueEntityProgress.worked}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if neededCount !== null}
|
||||||
<div class="summary-card unworked">
|
<div class="summary-card unworked">
|
||||||
<span class="summary-label">Needed:</span>
|
<span class="summary-label">Needed:</span>
|
||||||
<span class="summary-value">{neededCount}</span>
|
<span class="summary-value">{neededCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-card" style="background-color: #e3f2fd; border-color: #2196f3;">
|
||||||
|
<span class="summary-label">Target:</span>
|
||||||
|
<span class="summary-value">{targetCount}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user