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",
|
||||
"name": "RS-44 Satellite",
|
||||
"name": "44 on 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.",
|
||||
"category": "custom",
|
||||
"category": "satellite",
|
||||
"rules": {
|
||||
"type": "counter",
|
||||
"target": 44,
|
||||
@@ -19,5 +19,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"modeGroups": {}
|
||||
}
|
||||
@@ -44,6 +44,14 @@ import {
|
||||
getAwardProgressDetails,
|
||||
getAwardEntityBreakdown,
|
||||
} from './services/awards.service.js';
|
||||
import {
|
||||
getAllAwardDefinitions,
|
||||
getAwardDefinition,
|
||||
createAwardDefinition,
|
||||
updateAwardDefinition,
|
||||
deleteAwardDefinition,
|
||||
testAwardCalculation,
|
||||
} from './services/awards-admin.service.js';
|
||||
import {
|
||||
getAutoSyncSettings,
|
||||
updateAutoSyncSettings,
|
||||
@@ -1445,6 +1453,220 @@ const app = new Elysia()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* ================================================================
|
||||
* AWARD MANAGEMENT ROUTES (Admin Only)
|
||||
* ================================================================
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/admin/awards
|
||||
* Get all award definitions (admin only)
|
||||
*/
|
||||
.get('/api/admin/awards', async ({ user, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const awards = await getAllAwardDefinitions();
|
||||
return {
|
||||
success: true,
|
||||
awards,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching award definitions', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch award definitions',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* GET /api/admin/awards/:id
|
||||
* Get a single award definition (admin only)
|
||||
*/
|
||||
.get('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const award = await getAwardDefinition(params.id);
|
||||
|
||||
if (!award) {
|
||||
set.status = 404;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Award not found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
award,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error fetching award definition', { error: error.message, userId: user.id });
|
||||
set.status = 500;
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to fetch award definition',
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/admin/awards
|
||||
* Create a new award definition (admin only)
|
||||
*/
|
||||
.post(
|
||||
'/api/admin/awards',
|
||||
async ({ user, body, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const award = await createAwardDefinition(body);
|
||||
return {
|
||||
success: true,
|
||||
award,
|
||||
message: 'Award definition created successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error creating award definition', { error: error.message, userId: user.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
id: t.String(),
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
caption: t.String(),
|
||||
category: t.String(),
|
||||
rules: t.Any(),
|
||||
modeGroups: t.Optional(t.Any()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PUT /api/admin/awards/:id
|
||||
* Update an award definition (admin only)
|
||||
*/
|
||||
.put(
|
||||
'/api/admin/awards/:id',
|
||||
async ({ user, params, body, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const award = await updateAwardDefinition(params.id, body);
|
||||
return {
|
||||
success: true,
|
||||
award,
|
||||
message: 'Award definition updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error updating award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
id: t.Optional(t.String()),
|
||||
name: t.String(),
|
||||
description: t.String(),
|
||||
caption: t.String(),
|
||||
category: t.String(),
|
||||
rules: t.Any(),
|
||||
modeGroups: t.Optional(t.Any()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/awards/:id
|
||||
* Delete an award definition (admin only)
|
||||
*/
|
||||
.delete('/api/admin/awards/:id', async ({ user, params, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deleteAwardDefinition(params.id);
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
message: 'Award definition deleted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error deleting award definition', { error: error.message, userId: user.id, awardId: params.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/admin/awards/:id/test
|
||||
* Test award calculation (admin only)
|
||||
*/
|
||||
.post(
|
||||
'/api/admin/awards/:id/test',
|
||||
async ({ user, params, body, set }) => {
|
||||
if (!user || !user.isAdmin) {
|
||||
set.status = !user ? 401 : 403;
|
||||
return { success: false, error: !user ? 'Unauthorized' : 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Use provided userId or admin's own account
|
||||
const testUserId = body.userId || user.id;
|
||||
const awardDefinition = body.awardDefinition || null;
|
||||
const result = await testAwardCalculation(params.id, testUserId, awardDefinition);
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error testing award calculation', { error: error.message, userId: user.id, awardId: params.id });
|
||||
set.status = 400;
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
userId: t.Optional(t.Integer()),
|
||||
awardDefinition: t.Optional(t.Any()),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* ================================================================
|
||||
* AUTO-SYNC SETTINGS ROUTES
|
||||
|
||||
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 {
|
||||
// Auto-discover all JSON files in the award-definitions directory
|
||||
const files = readdirSync(AWARD_DEFINITIONS_DIR)
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.sort();
|
||||
.filter(f => f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
@@ -47,12 +46,47 @@ function loadAwardDefinitions() {
|
||||
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
|
||||
cachedAwardDefinitions = 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
|
||||
*/
|
||||
|
||||
@@ -130,3 +130,29 @@ export const autoSyncAPI = {
|
||||
|
||||
getSchedulerStatus: () => apiRequest('/auto-sync/scheduler/status'),
|
||||
};
|
||||
|
||||
// Awards Admin API
|
||||
export const awardsAdminAPI = {
|
||||
getAll: () => apiRequest('/admin/awards'),
|
||||
|
||||
getById: (id) => apiRequest(`/admin/awards/${id}`),
|
||||
|
||||
create: (data) => apiRequest('/admin/awards', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
update: (id, data) => apiRequest(`/admin/awards/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/admin/awards/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
test: (id, userId, awardDefinition) => apiRequest(`/admin/awards/${id}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, awardDefinition }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
let impersonationStatus = null;
|
||||
|
||||
// UI state
|
||||
let selectedTab = 'overview'; // 'overview', 'users', 'actions'
|
||||
let selectedTab = 'overview'; // 'overview', 'users', 'awards', 'actions'
|
||||
let showImpersonationModal = false;
|
||||
let showDeleteUserModal = false;
|
||||
let showRoleChangeModal = false;
|
||||
@@ -226,6 +226,12 @@
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
class="tab {selectedTab === 'awards' ? 'active' : ''}"
|
||||
on:click={() => selectedTab = 'awards'}
|
||||
>
|
||||
Awards
|
||||
</button>
|
||||
<button
|
||||
class="tab {selectedTab === 'actions' ? 'active' : ''}"
|
||||
on:click={() => selectedTab = 'actions'}
|
||||
@@ -382,6 +388,30 @@
|
||||
</div>
|
||||
{/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 -->
|
||||
{#if selectedTab === 'actions'}
|
||||
<div class="tab-content">
|
||||
@@ -919,6 +949,50 @@
|
||||
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) {
|
||||
.users-header {
|
||||
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">
|
||||
{#if entities.length > 0 && entities[0].points !== undefined}
|
||||
{@const earnedPoints = filteredEntities.reduce((sum, e) => sum + (e.confirmed ? e.points : 0), 0)}
|
||||
{@const targetPoints = award.target}
|
||||
{@const neededPoints = Math.max(0, targetPoints - earnedPoints)}
|
||||
{@const targetPoints = award.rules?.target}
|
||||
{@const neededPoints = targetPoints !== undefined ? Math.max(0, targetPoints - earnedPoints) : null}
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Total Combinations:</span>
|
||||
<span class="summary-value">{filteredEntities.length}</span>
|
||||
@@ -564,6 +564,7 @@
|
||||
<span class="summary-label">Points:</span>
|
||||
<span class="summary-value">{earnedPoints}</span>
|
||||
</div>
|
||||
{#if neededPoints !== null}
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Needed:</span>
|
||||
<span class="summary-value">{neededPoints}</span>
|
||||
@@ -572,8 +573,10 @@
|
||||
<span class="summary-label">Target:</span>
|
||||
<span class="summary-value">{targetPoints}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{: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">
|
||||
<span class="summary-label">Total:</span>
|
||||
<span class="summary-value">{uniqueEntityProgress.total}</span>
|
||||
@@ -586,10 +589,16 @@
|
||||
<span class="summary-label">Worked:</span>
|
||||
<span class="summary-value">{uniqueEntityProgress.worked}</span>
|
||||
</div>
|
||||
{#if neededCount !== null}
|
||||
<div class="summary-card unworked">
|
||||
<span class="summary-label">Needed:</span>
|
||||
<span class="summary-value">{neededCount}</span>
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user