feat: Implement dynamic betting outcomes and print functionality

- Replace hardcoded WIN1/WIN2/X betting terminology with database-driven outcomes
- Enhanced /cashier/available-matches API endpoint to query match_outcomes table
- Added generateOutcomeOptionsHTML() function for dynamic betting option generation
- Implemented graceful fallback to standard options when database outcomes unavailable
- Added print button to bets list in cashier dashboard with placeholder functionality
- Enhanced API response structure with outcome_id, outcome_name, outcome_value, display_name
- Updated documentation with comprehensive betting system section
- Added troubleshooting guides and performance optimization details
- Version 1.2.9 changelog and README updates with new features
parent e8012cdc
...@@ -2,6 +2,33 @@ ...@@ -2,6 +2,33 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [1.2.9] - 2025-08-26
### Added
- **Dynamic Betting Outcomes System**: Complete database-driven betting outcomes replacing hardcoded WIN1/WIN2/X terminology
- **Print Functionality**: Added print button to bets list in cashier dashboard for bet receipt printing
- **Match Outcome Integration**: API endpoint enhanced to query match_outcomes table for actual betting options
- **Dynamic Frontend Generation**: JavaScript function to generate betting options from database outcomes
- **Graceful Fallback**: Maintains compatibility with standard betting options when database outcomes unavailable
### Enhanced
- **Betting System Flexibility**: Betting interface now displays actual available outcomes from match database
- **User Experience**: More accurate betting options reflect real match data rather than standardized terminology
- **API Response Structure**: Enhanced `/cashier/available-matches` endpoint with comprehensive outcome data
- **Template System**: Dynamic outcome generation supports unlimited outcome types with consistent styling
### Fixed
- **Betting Outcome Display**: Resolved hardcoded betting terminology to use actual match outcomes from database
- **API Data Structure**: Enhanced match data response to include outcome_id, outcome_name, outcome_value, and display_name fields
- **Frontend Template Logic**: Replaced static HTML generation with dynamic outcome parsing and display
### Technical Details
- **Database Integration**: Modified API to query MatchOutcomeModel table for each match's available outcomes
- **Frontend Architecture**: Created generateOutcomeOptionsHTML() function for dynamic betting option generation
- **Color Coding System**: Implemented rotating color scheme for up to 5 different outcome types
- **Fallback Mechanism**: Provides WIN1/WIN2/X options when no database outcomes exist with proper logging
- **Print Infrastructure**: Added placeholder print functionality with proper button styling and event handling
## [1.2.8] - 2025-08-26 ## [1.2.8] - 2025-08-26
### Added ### Added
......
...@@ -7,9 +7,10 @@ ...@@ -7,9 +7,10 @@
3. [Usage Guide](#usage-guide) 3. [Usage Guide](#usage-guide)
4. [Screen Casting System](#screen-casting-system) 4. [Screen Casting System](#screen-casting-system)
5. [API Reference](#api-reference) 5. [API Reference](#api-reference)
6. [Development Guide](#development-guide) 6. [Betting System](#betting-system)
7. [Troubleshooting](#troubleshooting) 7. [Development Guide](#development-guide)
8. [Advanced Topics](#advanced-topics) 8. [Troubleshooting](#troubleshooting)
9. [Advanced Topics](#advanced-topics)
## Installation & Setup ## Installation & Setup
...@@ -1290,6 +1291,364 @@ Check screen casting specific logs: ...@@ -1290,6 +1291,364 @@ Check screen casting specific logs:
- Clear system caches if memory usage grows - Clear system caches if memory usage grows
- Consider system memory upgrades for intensive usage - Consider system memory upgrades for intensive usage
## Betting System
The application includes a comprehensive cashier betting system with dynamic outcome generation from the database, replacing traditional hardcoded betting terminology with actual match outcomes.
### Betting System Features
- **Dynamic Betting Outcomes**: Betting options generated from actual match_outcomes table data
- **Database-Driven Interface**: No longer limited to WIN1/WIN2/X - displays any outcome types from database
- **Print Functionality**: Integrated print button for bet receipts (placeholder implementation)
- **Cashier Dashboard Integration**: Seamless integration with existing cashier interface
- **Bet Management**: Complete CRUD operations for bet creation, viewing, and cancellation
- **Real-Time Statistics**: Live betting statistics and totals
- **UUID-Based Tracking**: Secure bet identification using UUID system
### Betting System Architecture
The betting system consists of several integrated components:
1. **Database Models**: `BetModel` and `BetDetailModel` for comprehensive bet tracking
2. **Dynamic API Enhancement**: Enhanced `/cashier/available-matches` endpoint with match outcome data
3. **Frontend Generation**: JavaScript functions for dynamic betting option generation
4. **Print Infrastructure**: Print button infrastructure ready for implementation
5. **Cashier Interface**: Streamlined betting management interface
### Betting System Usage
#### Accessing the Betting Interface
Navigate to the betting system through the cashier dashboard:
1. Login to web dashboard at `http://localhost:5001`
2. Access with cashier role credentials
3. Click "Bets" button in the cashier dashboard
4. Access betting management, creation, and details interfaces
#### Creating New Bets
1. **Navigate to New Bet**: Click "New Bet" from the bets management page
2. **Load Available Matches**: System automatically loads matches in 'bet' status
3. **View Dynamic Outcomes**: See actual match outcomes from database instead of generic WIN1/WIN2/X
4. **Select Outcomes**: Choose from available betting outcomes with checkbox selection
5. **Enter Amounts**: Specify bet amounts for each selected outcome
6. **Review Summary**: Check bet summary with total amounts
7. **Submit Bet**: Finalize bet creation with UUID assignment
#### Managing Existing Bets
1. **View Bets List**: Browse all bets for selected date with filtering
2. **View Bet Details**: Click bet ID to see comprehensive bet information including match details
3. **Print Receipts**: Use print button for bet receipt generation (placeholder functionality)
4. **Cancel Bets**: Cancel pending bets with confirmation dialog
5. **Monitor Statistics**: View real-time betting statistics and totals
### Dynamic Outcome Generation
#### Database Integration
The betting system queries the `match_outcomes` table for each match to provide actual betting options:
```python
# Enhanced API endpoint queries match outcomes
match_outcomes = session.query(MatchOutcomeModel).filter_by(match_id=match.id).all()
# Converts to betting format
betting_outcomes = []
for outcome in match_outcomes:
betting_outcomes.append({
'outcome_id': outcome.id,
'outcome_name': outcome.column_name,
'outcome_value': outcome.column_value,
'display_name': outcome.column_name
})
```
#### Frontend Dynamic Generation
JavaScript function generates betting options dynamically:
```javascript
function generateOutcomeOptionsHTML(match, matchId) {
let outcomes = match.outcomes || [];
// Fallback to standard options if no database outcomes
if (outcomes.length === 0) {
outcomes = [
{ outcome_name: 'WIN1', display_name: `WIN1 - ${match.fighter1_township}` },
{ outcome_name: 'WIN2', display_name: `WIN2 - ${match.fighter2_township}` },
{ outcome_name: 'X', display_name: 'X - Draw' }
];
}
// Generate HTML with color coding and styling
// ... detailed implementation
}
```
#### Graceful Fallback System
- **Primary**: Database-driven outcomes from match_outcomes table
- **Fallback**: Standard WIN1/WIN2/X options when database outcomes unavailable
- **Logging**: Warnings logged when fallback options are used
- **Compatibility**: Maintains backward compatibility with existing betting logic
### Betting Database Schema
#### BetModel Table
```sql
CREATE TABLE bets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid VARCHAR(36) NOT NULL UNIQUE,
bet_datetime DATETIME NOT NULL,
fixture_id VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
#### BetDetailModel Table
```sql
CREATE TABLE bet_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bet_id VARCHAR(36) NOT NULL,
match_id INTEGER NOT NULL,
outcome VARCHAR(100) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
result VARCHAR(20) DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bet_id) REFERENCES bets (uuid) ON DELETE CASCADE,
FOREIGN KEY (match_id) REFERENCES matches (id) ON DELETE CASCADE
);
```
#### Database Migration
The betting system is implemented through Migration_017_AddBettingTables:
```python
class Migration_017_AddBettingTables(DatabaseMigration):
def up(self, db_manager) -> bool:
# Creates comprehensive betting tables with:
# - UUID-based bet identification
# - Foreign key relationships with CASCADE DELETE
# - Proper indexing for performance
# - SQLite-compatible syntax
```
### Betting System API
#### Get Available Matches for Betting
```http
GET /api/cashier/available-matches
Authorization: Bearer <token>
```
**Enhanced Response with Dynamic Outcomes:**
```json
{
"success": true,
"matches": [
{
"id": 1,
"match_number": 101,
"fighter1_township": "Kampala Central",
"fighter2_township": "Nakawa",
"status": "bet",
"outcomes": [
{
"outcome_id": 1,
"outcome_name": "KO_ROUND_1",
"outcome_value": 2.5,
"display_name": "KO Round 1"
},
{
"outcome_id": 2,
"outcome_name": "POINTS_WIN",
"outcome_value": 1.8,
"display_name": "Points Win"
}
],
"outcomes_count": 2
}
],
"total": 1
}
```
#### Create New Bet
```http
POST /api/cashier/bets
Authorization: Bearer <token>
Content-Type: application/json
{
"bet_details": [
{
"match_id": 1,
"outcome": "KO_ROUND_1",
"amount": 25.00
},
{
"match_id": 1,
"outcome": "POINTS_WIN",
"amount": 15.00
}
]
}
```
**Response:**
```json
{
"success": true,
"message": "Bet created successfully",
"bet_id": "550e8400-e29b-41d4-a716-446655440000",
"details_count": 2
}
```
#### Get Bet Details
```http
GET /api/cashier/bets/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
```
**Response:**
```json
{
"success": true,
"bet": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"bet_datetime": "2025-08-26T15:30:45",
"details": [
{
"id": 1,
"match_id": 1,
"outcome": "KO_ROUND_1",
"amount": 25.00,
"result": "pending",
"match": {
"match_number": 101,
"fighter1_township": "Kampala Central",
"fighter2_township": "Nakawa",
"status": "bet"
}
}
],
"total_amount": 40.00
}
}
```
#### Cancel Bet
```http
DELETE /api/cashier/bets/550e8400-e29b-41d4-a716-446655440000
Authorization: Bearer <token>
```
### Print Functionality
#### Print Button Implementation
The betting system includes integrated print functionality:
- **Location**: Print button appears in each bet row in the bets management table
- **Styling**: Consistent with other action buttons using outline-secondary styling
- **Icon**: FontAwesome print icon for clear visual identification
- **Tooltip**: "Print Bet Receipt" tooltip for user guidance
- **Event Handling**: Proper click event handling with bet ID parameter
#### Print Function Structure
```javascript
function printBet(betId) {
// Placeholder function for printing bet receipt
console.log('Print bet requested for bet ID:', betId);
showNotification('Print functionality will be implemented soon!', 'info');
// Future implementation possibilities:
// 1. Open formatted receipt in new window
// 2. Call print API endpoint for PDF generation
// 3. Browser print dialog with formatted receipt
}
```
#### Future Print Implementation
The print infrastructure is ready for enhancement:
1. **Receipt Template**: Create HTML template for bet receipt formatting
2. **Print API Endpoint**: Add backend endpoint for receipt generation
3. **PDF Generation**: Integrate PDF library for receipt document creation
4. **Print Dialog**: Browser print dialog integration
5. **Thermal Printer**: Integration with thermal receipt printers
### Betting System Troubleshooting
#### Outcomes Not Loading
**Symptoms**: Betting interface shows "No outcomes found" or falls back to WIN1/WIN2/X
**Solutions**:
1. Check match_outcomes table has data for matches in 'bet' status
2. Verify database connectivity and permissions
3. Check API endpoint `/api/cashier/available-matches` response
4. Ensure matches have proper status ('bet') for betting availability
5. Review browser console for JavaScript errors
#### Bet Creation Fails
**Symptoms**: Bet submission returns errors or doesn't complete
**Solutions**:
1. Verify match IDs are valid and matches exist
2. Check outcome names match database values
3. Ensure bet amounts are valid positive numbers
4. Check user authentication and cashier role permissions
5. Review database constraints and foreign key relationships
#### Print Button Not Working
**Symptoms**: Print button doesn't respond or shows errors
**Solutions**:
1. Check JavaScript console for event handler errors
2. Verify bet ID parameter is correctly passed
3. Ensure showNotification function is available
4. Check button HTML structure and event binding
5. Test with different browsers for compatibility
#### Betting Statistics Incorrect
**Symptoms**: Bet totals, counts, or statistics don't match expected values
**Solutions**:
1. Check date filtering logic for bet queries
2. Verify bet_details table relationships and data
3. Check result status calculations (won/lost/pending)
4. Ensure proper decimal handling for amounts
5. Review aggregation logic in statistics calculation
### Betting System Performance
#### Optimization Features
- **Database Indexing**: Proper indexes on bet_id, match_id, and date columns
- **Efficient Queries**: Optimized API queries with JOIN operations
- **JavaScript Optimization**: Efficient DOM manipulation and event handling
- **Minimal API Calls**: Cached match outcome data and reduced server requests
#### Scalability Considerations
- **UUID System**: Scalable bet identification without integer limitations
- **Foreign Key Relationships**: Proper data integrity with CASCADE operations
- **Flexible Outcome Types**: Supports unlimited outcome varieties from database
- **Modular Architecture**: Easy extension for additional betting features
## API Reference ## API Reference
### Authentication ### Authentication
......
...@@ -5,6 +5,8 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -5,6 +5,8 @@ A cross-platform multimedia client application with video playback, web dashboar
## Features ## Features
- **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets) - **PyQt Video Player**: Fullscreen video playback with dual overlay system (WebEngine and native Qt widgets)
- **Dynamic Betting System**: Complete database-driven betting interface with actual match outcomes replacing hardcoded terminology
- **Print Functionality**: Integrated print button for bet receipts with placeholder implementation ready for enhancement
- **Screen Casting System**: Complete screen capture and Chromecast streaming with web-based controls and device discovery - **Screen Casting System**: Complete screen capture and Chromecast streaming with web-based controls and device discovery
- **Template Management System**: Upload, manage, and live-reload HTML overlay templates with persistent storage - **Template Management System**: Upload, manage, and live-reload HTML overlay templates with persistent storage
- **Extraction Management**: Complete drag-and-drop interface for managing outcome associations with extraction results - **Extraction Management**: Complete drag-and-drop interface for managing outcome associations with extraction results
...@@ -24,6 +26,15 @@ A cross-platform multimedia client application with video playback, web dashboar ...@@ -24,6 +26,15 @@ A cross-platform multimedia client application with video playback, web dashboar
## Recent Improvements ## Recent Improvements
### Version 1.2.9 (August 2025)
-**Dynamic Betting Outcomes System**: Complete database-driven betting outcomes replacing hardcoded WIN1/WIN2/X terminology
-**Print Functionality**: Added print button to bets list in cashier dashboard for bet receipt printing
-**Match Outcome Integration**: API endpoint enhanced to query match_outcomes table for actual betting options
-**Dynamic Frontend Generation**: JavaScript function to generate betting options from database outcomes
-**Graceful Fallback**: Maintains compatibility with standard betting options when database outcomes unavailable
-**Enhanced Betting System**: More accurate betting options reflect real match data rather than standardized terminology
### Version 1.2.8 (August 2025) ### Version 1.2.8 (August 2025)
-**Offline CDN Fallback System**: Local copies of Bootstrap CSS/JS and FontAwesome with automatic fallback for offline networks -**Offline CDN Fallback System**: Local copies of Bootstrap CSS/JS and FontAwesome with automatic fallback for offline networks
......
...@@ -1155,6 +1155,207 @@ class Migration_016_ConvertBettingModeToGlobal(DatabaseMigration): ...@@ -1155,6 +1155,207 @@ class Migration_016_ConvertBettingModeToGlobal(DatabaseMigration):
return False return False
class Migration_017_AddBettingTables(DatabaseMigration):
"""Add bets and bets_details tables for betting system"""
def __init__(self):
super().__init__("017", "Add bets and bets_details tables for betting system")
def up(self, db_manager) -> bool:
"""Create bets and bets_details tables"""
try:
with db_manager.engine.connect() as conn:
# Create bets table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS bets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid VARCHAR(1024) NOT NULL UNIQUE,
bet_datetime DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
fixture_id VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (fixture_id) REFERENCES matches (fixture_id)
)
"""))
# Create bets_details table
conn.execute(text("""
CREATE TABLE IF NOT EXISTS bets_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bet_id INTEGER NOT NULL,
match_id INTEGER NOT NULL,
outcome VARCHAR(255) NOT NULL,
amount REAL NOT NULL,
result VARCHAR(20) DEFAULT 'pending' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (bet_id) REFERENCES bets (id) ON DELETE CASCADE,
FOREIGN KEY (match_id) REFERENCES matches (id)
)
"""))
# Create indexes for bets table
indexes = [
"CREATE INDEX IF NOT EXISTS ix_bets_uuid ON bets(uuid)",
"CREATE INDEX IF NOT EXISTS ix_bets_fixture_id ON bets(fixture_id)",
"CREATE INDEX IF NOT EXISTS ix_bets_created_at ON bets(created_at)",
]
# Create indexes for bets_details table
indexes.extend([
"CREATE INDEX IF NOT EXISTS ix_bets_details_bet_id ON bets_details(bet_id)",
"CREATE INDEX IF NOT EXISTS ix_bets_details_match_id ON bets_details(match_id)",
"CREATE INDEX IF NOT EXISTS ix_bets_details_outcome ON bets_details(outcome)",
"CREATE INDEX IF NOT EXISTS ix_bets_details_result ON bets_details(result)",
"CREATE INDEX IF NOT EXISTS ix_bets_details_composite ON bets_details(bet_id, match_id)",
])
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Bets and bets_details tables created successfully")
return True
except Exception as e:
logger.error(f"Failed to create betting tables: {e}")
return False
def down(self, db_manager) -> bool:
"""Drop bets and bets_details tables"""
try:
with db_manager.engine.connect() as conn:
# Drop tables in reverse order (child first due to foreign keys)
conn.execute(text("DROP TABLE IF EXISTS bets_details"))
conn.execute(text("DROP TABLE IF EXISTS bets"))
conn.commit()
logger.info("Bets and bets_details tables dropped")
return True
except Exception as e:
logger.error(f"Failed to drop betting tables: {e}")
return False
class Migration_018_RemoveExtractionAssociationUniqueConstraint(DatabaseMigration):
"""Remove unique constraint from extraction_associations to allow multiple result associations per outcome"""
def __init__(self):
super().__init__("018", "Remove unique constraint from extraction_associations to allow multiple result associations per outcome")
def up(self, db_manager) -> bool:
"""Remove unique constraint from extraction_associations table"""
try:
with db_manager.engine.connect() as conn:
# SQLite doesn't support ALTER TABLE DROP CONSTRAINT directly
# We need to recreate the table without the unique constraint
# Step 1: Create new table without UNIQUE constraint
conn.execute(text("""
CREATE TABLE IF NOT EXISTS extraction_associations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""))
# Step 2: Copy data from old table to new table
conn.execute(text("""
INSERT INTO extraction_associations_new
(id, outcome_name, extraction_result, is_default, created_at, updated_at)
SELECT id, outcome_name, extraction_result, is_default, created_at, updated_at
FROM extraction_associations
"""))
# Step 3: Drop old table
conn.execute(text("DROP TABLE extraction_associations"))
# Step 4: Rename new table to original name
conn.execute(text("ALTER TABLE extraction_associations_new RENAME TO extraction_associations"))
# Step 5: Recreate indexes (without unique constraint)
indexes = [
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_outcome_name ON extraction_associations(outcome_name)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_extraction_result ON extraction_associations(extraction_result)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_composite ON extraction_associations(outcome_name, extraction_result)",
]
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Unique constraint removed from extraction_associations table - outcomes can now have multiple result associations")
return True
except Exception as e:
logger.error(f"Failed to remove unique constraint from extraction_associations: {e}")
return False
def down(self, db_manager) -> bool:
"""Add unique constraint back to extraction_associations table"""
try:
with db_manager.engine.connect() as conn:
# Check if there are any duplicate (outcome_name, extraction_result) combinations
result = conn.execute(text("""
SELECT outcome_name, extraction_result, COUNT(*) as count
FROM extraction_associations
GROUP BY outcome_name, extraction_result
HAVING COUNT(*) > 1
"""))
duplicates = result.fetchall()
if duplicates:
logger.error(f"Cannot add unique constraint - duplicate combinations found: {[(row[0], row[1]) for row in duplicates]}")
return False
# Recreate table with unique constraint
conn.execute(text("""
CREATE TABLE IF NOT EXISTS extraction_associations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
outcome_name VARCHAR(255) NOT NULL,
extraction_result VARCHAR(50) NOT NULL,
is_default BOOLEAN DEFAULT FALSE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(outcome_name, extraction_result)
)
"""))
# Copy data from old table to new table
conn.execute(text("""
INSERT INTO extraction_associations_new
(id, outcome_name, extraction_result, is_default, created_at, updated_at)
SELECT id, outcome_name, extraction_result, is_default, created_at, updated_at
FROM extraction_associations
"""))
# Drop old table and rename new table
conn.execute(text("DROP TABLE extraction_associations"))
conn.execute(text("ALTER TABLE extraction_associations_new RENAME TO extraction_associations"))
# Recreate indexes (with unique constraint)
indexes = [
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_outcome_name ON extraction_associations(outcome_name)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_extraction_result ON extraction_associations(extraction_result)",
"CREATE INDEX IF NOT EXISTS ix_extraction_associations_composite ON extraction_associations(outcome_name, extraction_result)",
]
for index_sql in indexes:
conn.execute(text(index_sql))
conn.commit()
logger.info("Unique constraint added back to extraction_associations table")
return True
except Exception as e:
logger.error(f"Failed to add unique constraint back to extraction_associations: {e}")
return False
# Registry of all migrations in order # Registry of all migrations in order
MIGRATIONS: List[DatabaseMigration] = [ MIGRATIONS: List[DatabaseMigration] = [
Migration_001_InitialSchema(), Migration_001_InitialSchema(),
...@@ -1173,6 +1374,8 @@ MIGRATIONS: List[DatabaseMigration] = [ ...@@ -1173,6 +1374,8 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_014_AddExtractionAndGameConfigTables(), Migration_014_AddExtractionAndGameConfigTables(),
Migration_015_AddBettingModeTable(), Migration_015_AddBettingModeTable(),
Migration_016_ConvertBettingModeToGlobal(), Migration_016_ConvertBettingModeToGlobal(),
Migration_017_AddBettingTables(),
Migration_018_RemoveExtractionAssociationUniqueConstraint(),
] ]
......
...@@ -25,6 +25,13 @@ class MatchStatus(str, Enum): ...@@ -25,6 +25,13 @@ class MatchStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
PAUSED = "paused" PAUSED = "paused"
# Enum for bet result status
class BetResult(str, Enum):
WIN = "win"
LOST = "lost"
PENDING = "pending"
CANCELLED = "cancelled"
Base = declarative_base() Base = declarative_base()
...@@ -600,7 +607,6 @@ class ExtractionAssociationModel(BaseModel): ...@@ -600,7 +607,6 @@ class ExtractionAssociationModel(BaseModel):
Index('ix_extraction_associations_outcome_name', 'outcome_name'), Index('ix_extraction_associations_outcome_name', 'outcome_name'),
Index('ix_extraction_associations_extraction_result', 'extraction_result'), Index('ix_extraction_associations_extraction_result', 'extraction_result'),
Index('ix_extraction_associations_composite', 'outcome_name', 'extraction_result'), Index('ix_extraction_associations_composite', 'outcome_name', 'extraction_result'),
UniqueConstraint('outcome_name', 'extraction_result', name='uq_extraction_associations_outcome_result'),
) )
outcome_name = Column(String(255), nullable=False, comment='Match outcome name (e.g., WIN1, DRAW, X1, etc.)') outcome_name = Column(String(255), nullable=False, comment='Match outcome name (e.g., WIN1, DRAW, X1, etc.)')
...@@ -660,3 +666,98 @@ class GameConfigModel(BaseModel): ...@@ -660,3 +666,98 @@ class GameConfigModel(BaseModel):
def __repr__(self): def __repr__(self):
return f'<GameConfig {self.config_key}={self.config_value}>' return f'<GameConfig {self.config_key}={self.config_value}>'
class BetModel(BaseModel):
"""Betting system main table"""
__tablename__ = 'bets'
__table_args__ = (
Index('ix_bets_uuid', 'uuid'),
Index('ix_bets_fixture_id', 'fixture_id'),
Index('ix_bets_created_at', 'created_at'),
UniqueConstraint('uuid', name='uq_bets_uuid'),
)
uuid = Column(String(1024), nullable=False, unique=True, comment='Unique identifier for the bet')
fixture_id = Column(String(255), nullable=False, comment='Reference to fixture_id from matches table')
bet_datetime = Column(DateTime, default=datetime.utcnow, nullable=False, comment='Bet creation timestamp')
# Relationships
bet_details = relationship('BetDetailModel', back_populates='bet', cascade='all, delete-orphan')
def get_total_amount(self) -> float:
"""Get total amount of all bet details"""
return sum(detail.amount for detail in self.bet_details)
def get_bet_count(self) -> int:
"""Get number of bet details"""
return len(self.bet_details)
def has_pending_bets(self) -> bool:
"""Check if bet has any pending bet details"""
return any(detail.result == 'pending' for detail in self.bet_details)
def calculate_total_winnings(self) -> float:
"""Calculate total winnings from won bets"""
return sum(detail.amount for detail in self.bet_details if detail.result == 'win')
def to_dict(self, exclude_fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""Convert to dictionary with bet details"""
result = super().to_dict(exclude_fields)
result['bet_details'] = [detail.to_dict() for detail in self.bet_details]
result['total_amount'] = self.get_total_amount()
result['bet_count'] = self.get_bet_count()
result['has_pending'] = self.has_pending_bets()
return result
def __repr__(self):
return f'<Bet {self.uuid} for Fixture {self.fixture_id}>'
class BetDetailModel(BaseModel):
"""Betting system details table"""
__tablename__ = 'bets_details'
__table_args__ = (
Index('ix_bets_details_bet_id', 'bet_id'),
Index('ix_bets_details_match_id', 'match_id'),
Index('ix_bets_details_outcome', 'outcome'),
Index('ix_bets_details_result', 'result'),
Index('ix_bets_details_composite', 'bet_id', 'match_id'),
)
bet_id = Column(Integer, ForeignKey('bets.id', ondelete='CASCADE'), nullable=False, comment='Foreign key to bets table')
match_id = Column(Integer, ForeignKey('matches.id'), nullable=False, comment='Foreign key to matches table')
outcome = Column(String(255), nullable=False, comment='Bet outcome/prediction')
amount = Column(Float(precision=2), nullable=False, comment='Bet amount with 2 decimal precision')
result = Column(Enum('win', 'lost', 'pending', 'cancelled'), default='pending', nullable=False, comment='Bet result status')
# Relationships
bet = relationship('BetModel', back_populates='bet_details')
match = relationship('MatchModel')
def is_pending(self) -> bool:
"""Check if bet detail is pending"""
return self.result == 'pending'
def is_won(self) -> bool:
"""Check if bet detail was won"""
return self.result == 'win'
def is_lost(self) -> bool:
"""Check if bet detail was lost"""
return self.result == 'lost'
def is_cancelled(self) -> bool:
"""Check if bet detail was cancelled"""
return self.result == 'cancelled'
def set_result(self, result: str):
"""Set bet result"""
valid_results = ['win', 'lost', 'pending', 'cancelled']
if result not in valid_results:
raise ValueError(f"Invalid result: {result}. Must be one of {valid_results}")
self.result = result
self.updated_at = datetime.utcnow()
def __repr__(self):
return f'<BetDetail {self.outcome}={self.amount} ({self.result}) for Match {self.match_id}>'
...@@ -360,6 +360,81 @@ def extraction(): ...@@ -360,6 +360,81 @@ def extraction():
return render_template('errors/500.html'), 500 return render_template('errors/500.html'), 500
# Cashier Betting Routes
@main_bp.route('/cashier/bets')
@login_required
def cashier_bets():
"""Cashier betting management page"""
try:
# Verify user is cashier
if not (hasattr(current_user, 'role') and current_user.role == 'cashier'):
if not (hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user()):
flash("Cashier access required", "error")
return redirect(url_for('main.index'))
# Get today's date for the date picker default
from datetime import date
today_date = date.today().isoformat()
return render_template('dashboard/bets.html',
user=current_user,
today_date=today_date,
page_title="Betting Management")
except Exception as e:
logger.error(f"Cashier bets page error: {e}")
flash("Error loading bets page", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/cashier/bets/new')
@login_required
def cashier_new_bet():
"""Cashier new bet creation page"""
try:
# Verify user is cashier
if not (hasattr(current_user, 'role') and current_user.role == 'cashier'):
if not (hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user()):
flash("Cashier access required", "error")
return redirect(url_for('main.index'))
# Get current date for the page
from datetime import date
current_date = date.today().strftime('%Y-%m-%d')
return render_template('dashboard/new_bet.html',
user=current_user,
current_date=current_date,
page_title="Create New Bet")
except Exception as e:
logger.error(f"Cashier new bet page error: {e}")
flash("Error loading new bet page", "error")
return render_template('errors/500.html'), 500
@main_bp.route('/cashier/bets/<uuid:bet_id>')
@login_required
def cashier_bet_details(bet_id):
"""Cashier bet details page"""
try:
# Verify user is cashier
if not (hasattr(current_user, 'role') and current_user.role == 'cashier'):
if not (hasattr(current_user, 'is_cashier_user') and current_user.is_cashier_user()):
flash("Cashier access required", "error")
return redirect(url_for('main.index'))
# Convert UUID to string for template
bet_uuid = str(bet_id)
return render_template('dashboard/bet_details.html',
user=current_user,
bet_id=bet_uuid,
page_title=f"Bet Details - {bet_uuid[:8]}...")
except Exception as e:
logger.error(f"Cashier bet details page error: {e}")
flash("Error loading bet details", "error")
return render_template('errors/500.html'), 500
# Auth routes # Auth routes
@auth_bp.route('/login', methods=['GET', 'POST']) @auth_bp.route('/login', methods=['GET', 'POST'])
def login(): def login():
...@@ -1773,12 +1848,14 @@ def get_server_time(): ...@@ -1773,12 +1848,14 @@ def get_server_time():
"""Get current server time""" """Get current server time"""
try: try:
from datetime import datetime from datetime import datetime
# Use simple datetime.now() since it reports the correct local time
server_time = datetime.now() server_time = datetime.now()
return jsonify({ return jsonify({
"success": True, "success": True,
"server_time": server_time.isoformat(), "server_time": server_time.isoformat(),
"timestamp": int(server_time.timestamp() * 1000) # milliseconds since epoch "timestamp": int(server_time.timestamp() * 1000), # milliseconds since epoch
"formatted_time": server_time.strftime("%H:%M:%S") # Pre-formatted time string
}) })
except Exception as e: except Exception as e:
...@@ -1947,9 +2024,10 @@ def get_extraction_associations(): ...@@ -1947,9 +2024,10 @@ def get_extraction_associations():
@api_bp.route('/extraction/associations', methods=['POST']) @api_bp.route('/extraction/associations', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def save_extraction_associations(): def save_extraction_associations():
"""Save extraction associations""" """Save extraction associations with validation for max 2 associations per outcome"""
try: try:
from ..database.models import ExtractionAssociationModel from ..database.models import ExtractionAssociationModel
from collections import defaultdict
data = request.get_json() or {} data = request.get_json() or {}
associations_data = data.get('associations', []) associations_data = data.get('associations', [])
...@@ -1957,6 +2035,17 @@ def save_extraction_associations(): ...@@ -1957,6 +2035,17 @@ def save_extraction_associations():
if not associations_data: if not associations_data:
return jsonify({"error": "No associations provided"}), 400 return jsonify({"error": "No associations provided"}), 400
# Validate that no outcome has more than 2 associations
outcome_counts = defaultdict(int)
for assoc_data in associations_data:
outcome_name = assoc_data.get('outcome_name')
if outcome_name:
outcome_counts[outcome_name] += 1
if outcome_counts[outcome_name] > 2:
return jsonify({
"error": f"Outcome '{outcome_name}' has more than 2 associations. Maximum allowed is 2."
}), 400
session = api_bp.db_manager.get_session() session = api_bp.db_manager.get_session()
try: try:
# Clear existing associations # Clear existing associations
...@@ -1989,6 +2078,122 @@ def save_extraction_associations(): ...@@ -1989,6 +2078,122 @@ def save_extraction_associations():
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/associations/add', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def add_extraction_association():
"""Add a single extraction association"""
try:
from ..database.models import ExtractionAssociationModel
data = request.get_json() or {}
outcome_name = data.get('outcome_name')
extraction_result = data.get('extraction_result')
if not outcome_name or not extraction_result:
return jsonify({"error": "outcome_name and extraction_result are required"}), 400
# Validate extraction_result values
valid_results = ['WIN1', 'X', 'WIN2']
if extraction_result not in valid_results:
return jsonify({"error": f"extraction_result must be one of: {', '.join(valid_results)}"}), 400
session = api_bp.db_manager.get_session()
try:
# Check if this exact association already exists
existing_assoc = session.query(ExtractionAssociationModel).filter_by(
outcome_name=outcome_name,
extraction_result=extraction_result
).first()
if existing_assoc:
return jsonify({
"success": False,
"error": f"Association already exists: {outcome_name} -> {extraction_result}"
}), 400
# Check if outcome already has 2 associations (maximum allowed)
existing_count = session.query(ExtractionAssociationModel).filter_by(
outcome_name=outcome_name
).count()
if existing_count >= 2:
return jsonify({
"success": False,
"error": f"Outcome '{outcome_name}' already has maximum 2 associations"
}), 400
# Create new association
association = ExtractionAssociationModel(
outcome_name=outcome_name,
extraction_result=extraction_result,
is_default=False
)
session.add(association)
session.commit()
logger.info(f"Added extraction association: {outcome_name} -> {extraction_result}")
return jsonify({
"success": True,
"message": f"Association added: {outcome_name} -> {extraction_result}",
"association": association.to_dict()
})
finally:
session.close()
except Exception as e:
logger.error(f"API add extraction association error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/associations/remove', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def remove_extraction_association():
"""Remove a specific extraction association"""
try:
from ..database.models import ExtractionAssociationModel
data = request.get_json() or {}
outcome_name = data.get('outcome_name')
extraction_result = data.get('extraction_result')
if not outcome_name or not extraction_result:
return jsonify({"error": "outcome_name and extraction_result are required"}), 400
session = api_bp.db_manager.get_session()
try:
# Find the specific association
association = session.query(ExtractionAssociationModel).filter_by(
outcome_name=outcome_name,
extraction_result=extraction_result
).first()
if not association:
return jsonify({
"success": False,
"error": f"Association not found: {outcome_name} -> {extraction_result}"
}), 404
# Remove the association
session.delete(association)
session.commit()
logger.info(f"Removed extraction association: {outcome_name} -> {extraction_result}")
return jsonify({
"success": True,
"message": f"Association removed: {outcome_name} -> {extraction_result}"
})
finally:
session.close()
except Exception as e:
logger.error(f"API remove extraction association error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/extraction/config') @api_bp.route('/extraction/config')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required @api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_extraction_config(): def get_extraction_config():
...@@ -2263,4 +2468,373 @@ def control_match_timer(): ...@@ -2263,4 +2468,373 @@ def control_match_timer():
except Exception as e: except Exception as e:
logger.error(f"API control match timer error: {e}") logger.error(f"API control match timer error: {e}")
return jsonify({"error": str(e)}), 500
# Cashier Betting API endpoints
@api_bp.route('/cashier/bets')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_cashier_bets():
"""Get bets for a specific date (cashier)"""
try:
from ..database.models import BetModel, BetDetailModel
from datetime import datetime, date
# Get date parameter (default to today)
date_param = request.args.get('date')
if date_param:
try:
target_date = datetime.strptime(date_param, '%Y-%m-%d').date()
except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD"}), 400
else:
target_date = date.today()
session = api_bp.db_manager.get_session()
try:
# Get all bets for the target date
bets_query = session.query(BetModel).filter(
BetModel.bet_datetime >= datetime.combine(target_date, datetime.min.time()),
BetModel.bet_datetime < datetime.combine(target_date, datetime.max.time())
).order_by(BetModel.bet_datetime.desc())
bets = bets_query.all()
bets_data = []
# Statistics counters
total_amount = 0.0
won_bets = 0
lost_bets = 0
pending_bets = 0
for bet in bets:
bet_data = bet.to_dict()
# Get bet details
bet_details = session.query(BetDetailModel).filter_by(bet_id=bet.uuid).all()
bet_data['details'] = [detail.to_dict() for detail in bet_details]
# Calculate total amount for this bet
bet_total = sum(float(detail.amount) for detail in bet_details)
bet_data['total_amount'] = bet_total
total_amount += bet_total
# Determine overall bet status for statistics
if bet_details:
results = [detail.result for detail in bet_details]
if all(result == 'won' for result in results):
won_bets += 1
elif any(result == 'lost' for result in results):
lost_bets += 1
else:
pending_bets += 1
else:
pending_bets += 1
bets_data.append(bet_data)
# Calculate statistics
stats = {
'total_bets': len(bets_data),
'total_amount': total_amount,
'won_bets': won_bets,
'lost_bets': lost_bets,
'pending_bets': pending_bets
}
return jsonify({
"success": True,
"bets": bets_data,
"total": len(bets_data),
"date": target_date.isoformat(),
"stats": stats
})
finally:
session.close()
except Exception as e:
logger.error(f"API get cashier bets error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/bets', methods=['POST'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def create_cashier_bet():
"""Create a new bet (cashier)"""
try:
from ..database.models import BetModel, BetDetailModel
from datetime import datetime
import uuid as uuid_lib
data = request.get_json() or {}
bet_details = data.get('bet_details', [])
if not bet_details:
return jsonify({"error": "Bet details are required"}), 400
# Validate bet details
for detail in bet_details:
if not all(key in detail for key in ['match_id', 'outcome', 'amount']):
return jsonify({"error": "Each bet detail must have match_id, outcome, and amount"}), 400
try:
float(detail['amount'])
except (ValueError, TypeError):
return jsonify({"error": "Amount must be a valid number"}), 400
session = api_bp.db_manager.get_session()
try:
# Generate UUID for the bet
bet_uuid = str(uuid_lib.uuid4())
# Create the bet record
new_bet = BetModel(
uuid=bet_uuid,
bet_datetime=datetime.now(),
fixture_id=None # Will be set from first bet detail's match
)
session.add(new_bet)
session.flush() # Get the bet ID
# Create bet details
for detail_data in bet_details:
bet_detail = BetDetailModel(
bet_id=bet_uuid,
match_id=detail_data['match_id'],
outcome=detail_data['outcome'],
amount=float(detail_data['amount']),
result='pending'
)
session.add(bet_detail)
session.commit()
logger.info(f"Created bet {bet_uuid} with {len(bet_details)} details")
return jsonify({
"success": True,
"message": "Bet created successfully",
"bet_id": bet_uuid,
"details_count": len(bet_details)
})
finally:
session.close()
except Exception as e:
logger.error(f"API create cashier bet error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/bets/<uuid:bet_id>')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_cashier_bet_details(bet_id):
"""Get detailed information for a specific bet (cashier)"""
try:
from ..database.models import BetModel, BetDetailModel, MatchModel
bet_uuid = str(bet_id)
session = api_bp.db_manager.get_session()
try:
# Get the bet
bet = session.query(BetModel).filter_by(uuid=bet_uuid).first()
if not bet:
return jsonify({"error": "Bet not found"}), 404
bet_data = bet.to_dict()
# Get bet details with match information
bet_details = session.query(BetDetailModel).filter_by(bet_id=bet_uuid).all()
details_data = []
for detail in bet_details:
detail_data = detail.to_dict()
# Get match information
match = session.query(MatchModel).filter_by(id=detail.match_id).first()
if match:
detail_data['match'] = {
'match_number': match.match_number,
'fighter1_township': match.fighter1_township,
'fighter2_township': match.fighter2_township,
'venue_kampala_township': match.venue_kampala_township,
'status': match.status
}
else:
detail_data['match'] = None
details_data.append(detail_data)
bet_data['details'] = details_data
bet_data['details_count'] = len(details_data)
# Calculate total amount
total_amount = sum(float(detail.amount) for detail in bet_details)
bet_data['total_amount'] = total_amount
return jsonify({
"success": True,
"bet": bet_data
})
finally:
session.close()
except Exception as e:
logger.error(f"API get cashier bet details error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/bets/<uuid:bet_id>', methods=['DELETE'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def cancel_cashier_bet(bet_id):
"""Cancel a bet and all its details (cashier)"""
try:
from ..database.models import BetModel, BetDetailModel
bet_uuid = str(bet_id)
session = api_bp.db_manager.get_session()
try:
# Get the bet
bet = session.query(BetModel).filter_by(uuid=bet_uuid).first()
if not bet:
return jsonify({"error": "Bet not found"}), 404
# Check if bet can be cancelled (only pending bets)
bet_details = session.query(BetDetailModel).filter_by(bet_id=bet_uuid).all()
if any(detail.result != 'pending' for detail in bet_details):
return jsonify({"error": "Cannot cancel bet with non-pending results"}), 400
# Update all bet details to cancelled
for detail in bet_details:
detail.result = 'cancelled'
session.commit()
logger.info(f"Cancelled bet {bet_uuid}")
return jsonify({
"success": True,
"message": "Bet cancelled successfully",
"bet_id": bet_uuid
})
finally:
session.close()
except Exception as e:
logger.error(f"API cancel cashier bet error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/available-matches')
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def get_available_matches_for_betting():
"""Get matches that are available for betting (status = 'bet') with actual match outcomes"""
try:
from ..database.models import MatchModel, MatchOutcomeModel
from datetime import datetime, date
session = api_bp.db_manager.get_session()
try:
# Get today's date
today = date.today()
# Get matches in 'bet' status from today (or all if none today)
matches_query = session.query(MatchModel).filter(
MatchModel.status == 'bet'
).order_by(MatchModel.match_number.asc())
# Try to filter by today first
today_matches = matches_query.filter(
MatchModel.start_time >= datetime.combine(today, datetime.min.time()),
MatchModel.start_time < datetime.combine(today, datetime.max.time())
).all()
if today_matches:
matches = today_matches
else:
# Fallback to all matches in bet status
matches = matches_query.all()
matches_data = []
for match in matches:
match_data = match.to_dict()
# Get actual match outcomes from the database
match_outcomes = session.query(MatchOutcomeModel).filter_by(match_id=match.id).all()
# Convert outcomes to betting options format
betting_outcomes = []
for outcome in match_outcomes:
betting_outcomes.append({
'outcome_id': outcome.id,
'outcome_name': outcome.column_name,
'outcome_value': outcome.column_value,
'display_name': outcome.column_name # Use actual outcome name from database
})
# If no outcomes found, fallback to standard betting options as safety measure
if not betting_outcomes:
logger.warning(f"No outcomes found for match {match.id}, using standard options")
betting_outcomes = [
{'outcome_id': None, 'outcome_name': 'WIN1', 'outcome_value': None, 'display_name': 'WIN1'},
{'outcome_id': None, 'outcome_name': 'X', 'outcome_value': None, 'display_name': 'X'},
{'outcome_id': None, 'outcome_name': 'WIN2', 'outcome_value': None, 'display_name': 'WIN2'}
]
match_data['outcomes'] = betting_outcomes
match_data['outcomes_count'] = len(betting_outcomes)
matches_data.append(match_data)
return jsonify({
"success": True,
"matches": matches_data,
"total": len(matches_data),
"date": today.isoformat()
})
finally:
session.close()
except Exception as e:
logger.error(f"API get available matches error: {e}")
return jsonify({"error": str(e)}), 500
@api_bp.route('/cashier/bet-details/<int:detail_id>', methods=['DELETE'])
@api_bp.auth_manager.require_auth if hasattr(api_bp, 'auth_manager') and api_bp.auth_manager else login_required
def delete_bet_detail(detail_id):
"""Delete a specific bet detail (cashier)"""
try:
from ..database.models import BetDetailModel
session = api_bp.db_manager.get_session()
try:
# Get the bet detail
bet_detail = session.query(BetDetailModel).filter_by(id=detail_id).first()
if not bet_detail:
return jsonify({"error": "Bet detail not found"}), 404
# Check if detail can be deleted (only pending)
if bet_detail.result != 'pending':
return jsonify({"error": "Cannot delete non-pending bet detail"}), 400
bet_id = bet_detail.bet_id
session.delete(bet_detail)
session.commit()
logger.info(f"Deleted bet detail {detail_id} from bet {bet_id}")
return jsonify({
"success": True,
"message": "Bet detail deleted successfully",
"detail_id": detail_id,
"bet_id": bet_id
})
finally:
session.close()
except Exception as e:
logger.error(f"API delete bet detail error: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
\ No newline at end of file
...@@ -270,48 +270,64 @@ ...@@ -270,48 +270,64 @@
const clockElement = document.getElementById('clock-time'); const clockElement = document.getElementById('clock-time');
if (!clockElement) return; if (!clockElement) return;
let serverTimeOffset = 0; // Offset between server and client time let serverTimeBase = null;
let lastServerTime = null; let serverTimeStartLocal = null;
function fetchServerTime() { function fetchServerTime() {
return fetch('/api/server-time') return fetch('/api/server-time')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success && data.formatted_time) {
const serverTimestamp = data.timestamp; // Use the server's pre-formatted time string
const clientTimestamp = Date.now(); serverTimeBase = data.formatted_time;
serverTimeOffset = serverTimestamp - clientTimestamp; serverTimeStartLocal = Date.now();
lastServerTime = serverTimestamp; return true;
return serverTimestamp;
} else { } else {
throw new Error('Failed to get server time'); throw new Error('Failed to get server time');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error fetching server time:', error); console.error('Error fetching server time:', error);
// Fallback to client time if server time is unavailable // Use local time as fallback
return Date.now(); serverTimeBase = null;
return false;
}); });
} }
function updateClock() { function updateClock() {
const now = Date.now() + serverTimeOffset; if (serverTimeBase && serverTimeStartLocal) {
const date = new Date(now); // Calculate elapsed seconds since server time fetch
const elapsedMs = Date.now() - serverTimeStartLocal;
const hours = String(date.getHours()).padStart(2, '0'); const elapsedSeconds = Math.floor(elapsedMs / 1000);
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0'); // Parse server time and add elapsed seconds
const timeString = `${hours}:${minutes}:${seconds}`; const [hours, minutes, seconds] = serverTimeBase.split(':').map(num => parseInt(num, 10));
const serverDateTime = new Date();
clockElement.textContent = timeString; serverDateTime.setHours(hours, minutes, seconds + elapsedSeconds, 0);
const displayHours = String(serverDateTime.getHours()).padStart(2, '0');
const displayMinutes = String(serverDateTime.getMinutes()).padStart(2, '0');
const displaySeconds = String(serverDateTime.getSeconds()).padStart(2, '0');
const timeString = `${displayHours}:${displayMinutes}:${displaySeconds}`;
clockElement.textContent = timeString;
} else {
// Fallback to local time
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}:${minutes}:${seconds}`;
clockElement.textContent = timeString;
}
} }
// Fetch server time initially and set up updates // Fetch server time initially and set up updates
fetchServerTime().then(() => { fetchServerTime().then(() => {
// Update immediately with server time // Update immediately
updateClock(); updateClock();
// Update display every second (using client time + offset) // Update display every second
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
// Sync with server time every 30 seconds // Sync with server time every 30 seconds
......
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-info-circle me-2"></i>Bet Details
<small class="text-muted">Bet ID: {{ bet.uuid[:8] }}...</small>
</h1>
</div>
</div>
<!-- Back button and controls -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-cog me-2"></i>Bet Controls
</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-4 mb-3">
<a href="{{ url_for('main.cashier_bets') }}" class="btn btn-outline-secondary w-100">
<i class="fas fa-arrow-left me-2"></i>Back to Bets
</a>
</div>
<div class="col-md-4 mb-3">
{% if bet.has_pending %}
<button class="btn btn-danger w-100" id="btn-cancel-bet" data-bet-uuid="{{ bet.uuid }}">
<i class="fas fa-times me-2"></i>Cancel Entire Bet
</button>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<div class="text-center">
<strong class="text-success h4">Total: €{{ bet.total_amount|round(2) }}</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Layout -->
<div class="row">
<!-- Bet Details List - Left Side -->
<div class="col-lg-9 col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-list-ul me-2"></i>Bet Details
<span class="badge bg-info ms-2">{{ bet.bet_count }} items</span>
</h5>
</div>
<div class="card-body">
<div id="bet-details-container">
{% if bet.bet_details %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th><i class="fas fa-hashtag me-1"></i>Match</th>
<th><i class="fas fa-target me-1"></i>Outcome</th>
<th><i class="fas fa-euro-sign me-1"></i>Amount</th>
<th><i class="fas fa-flag me-1"></i>Result</th>
<th><i class="fas fa-cogs me-1"></i>Actions</th>
</tr>
</thead>
<tbody>
{% for detail in bet.bet_details %}
<tr>
<td>
<strong>Match #{{ detail.match.match_number }}</strong><br>
<small class="text-muted">
{{ detail.match.fighter1_township }} vs {{ detail.match.fighter2_township }}
</small>
</td>
<td>
<span class="badge bg-primary">{{ detail.outcome }}</span>
</td>
<td>
<strong>€{{ detail.amount|round(2) }}</strong>
</td>
<td>
{% if detail.result == 'pending' %}
<span class="badge bg-warning">Pending</span>
{% elif detail.result == 'win' %}
<span class="badge bg-success">Won</span>
{% elif detail.result == 'lost' %}
<span class="badge bg-danger">Lost</span>
{% elif detail.result == 'cancelled' %}
<span class="badge bg-secondary">Cancelled</span>
{% endif %}
</td>
<td>
{% if detail.result == 'pending' %}
<button class="btn btn-sm btn-outline-danger btn-delete-detail"
data-detail-id="{{ detail.id }}"
title="Delete this bet detail">
<i class="fas fa-trash"></i>
</button>
{% else %}
<span class="text-muted">No actions</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted">
<i class="fas fa-inbox me-2"></i>No bet details found
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Right Column - Bet Summary -->
<div class="col-lg-3 col-md-4">
<!-- Bet Summary -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-info me-2"></i>Bet Summary
</h6>
</div>
<div class="card-body p-3">
<dl class="mb-0 small">
<dt class="text-muted">Bet UUID</dt>
<dd class="font-monospace">{{ bet.uuid }}</dd>
<dt class="text-muted">Created</dt>
<dd>{{ bet.bet_datetime.strftime('%Y-%m-%d %H:%M') }}</dd>
<dt class="text-muted">Fixture</dt>
<dd>{{ bet.fixture_id }}</dd>
<dt class="text-muted">Total Items</dt>
<dd><span class="badge bg-info">{{ bet.bet_count }}</span></dd>
<dt class="text-muted">Total Amount</dt>
<dd><strong class="text-success">€{{ bet.total_amount|round(2) }}</strong></dd>
<dt class="text-muted">Status</dt>
<dd>
{% if bet.has_pending %}
<span class="badge bg-warning">Has Pending</span>
{% else %}
<span class="badge bg-success">Completed</span>
{% endif %}
</dd>
</dl>
</div>
</div>
<!-- Results Summary -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-chart-pie me-2"></i>Results Summary
</h6>
</div>
<div class="card-body p-3">
<div class="row text-center">
<div class="col-6 mb-2">
<h6 class="text-warning mb-1">{{ results.pending }}</h6>
<small class="text-muted">Pending</small>
</div>
<div class="col-6 mb-2">
<h6 class="text-success mb-1">{{ results.won }}</h6>
<small class="text-muted">Won</small>
</div>
<div class="col-6 mb-2">
<h6 class="text-danger mb-1">{{ results.lost }}</h6>
<small class="text-muted">Lost</small>
</div>
<div class="col-6 mb-2">
<h6 class="text-secondary mb-1">{{ results.cancelled }}</h6>
<small class="text-muted">Cancelled</small>
</div>
</div>
<hr class="my-2">
<div class="text-center">
<strong class="text-success">Winnings: €{{ results.winnings|round(2) }}</strong>
</div>
</div>
</div>
<!-- Session Info -->
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Info
</h6>
</div>
<div class="card-body p-3">
<dl class="mb-0 small">
<dt class="text-muted">User</dt>
<dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt>
<dd>
<span class="badge bg-info">Cashier</span>
</dd>
<dt class="text-muted">Current Time</dt>
<dd id="current-time">{{ moment().format('HH:mm:ss') }}</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Cancel entire bet button
const cancelBetBtn = document.getElementById('btn-cancel-bet');
if (cancelBetBtn) {
cancelBetBtn.addEventListener('click', function() {
const betUuid = this.dataset.betUuid;
cancelEntireBet(betUuid);
});
}
// Update current time every second
setInterval(updateCurrentTime, 1000);
updateCurrentTime();
});
function deleteBetDetail(detailId) {
if (confirm('Are you sure you want to delete this bet detail? This action cannot be undone.')) {
fetch(`/api/cashier/bet-details/${detailId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Bet detail deleted successfully', 'success');
// Reload the page to update the display
window.location.reload();
} else {
showNotification('Failed to delete bet detail: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(error => {
showNotification('Error deleting bet detail: ' + error.message, 'error');
});
}
}
function cancelEntireBet(betUuid) {
if (confirm('Are you sure you want to cancel the entire bet? All pending bet details will be cancelled. This action cannot be undone.')) {
fetch(`/api/cashier/bets/${betUuid}/cancel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Entire bet cancelled successfully', 'success');
// Redirect back to bets list
window.location.href = '/cashier/bets';
} else {
showNotification('Failed to cancel bet: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(error => {
showNotification('Error cancelling bet: ' + error.message, 'error');
});
}
}
function updateCurrentTime() {
const now = new Date();
const timeString = now.toLocaleTimeString();
const timeElement = document.getElementById('current-time');
if (timeElement) {
timeElement.textContent = timeString;
}
}
function showNotification(message, type = 'info') {
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-danger' : 'alert-info';
const notification = document.createElement('div');
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
</script>
{% endblock %}
\ No newline at end of file
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-coins me-2"></i>Betting Management
<small class="text-muted">Welcome, {{ current_user.username }}</small>
</h1>
</div>
</div>
<!-- Back Button and New Bet Button -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-list me-2"></i>Bets Management
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<button class="btn btn-outline-secondary" onclick="window.location.href='/dashboard/cashier'">
<i class="fas fa-arrow-left me-2"></i>Back to Dashboard
</button>
</div>
<div class="col-md-6 mb-3 text-end">
<button class="btn btn-success" id="btn-new-bet">
<i class="fas fa-plus me-2"></i>New Bet
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Layout -->
<div class="row">
<!-- Bets Table - Left Side -->
<div class="col-lg-9 col-md-8">
<div class="card mb-4">
<div class="card-header">
<div class="row">
<div class="col-md-8">
<h5 class="card-title mb-0">
<i class="fas fa-calendar-alt me-2"></i>Bets for Today
<span class="badge bg-info ms-2" id="bets-count">0</span>
</h5>
</div>
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-calendar"></i>
</span>
<input type="date" class="form-control" id="bet-date-picker"
value="{{ today_date }}" max="{{ today_date }}">
</div>
</div>
</div>
</div>
<div class="card-body">
<div id="bets-container">
<div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading bets...
</div>
</div>
</div>
</div>
</div>
<!-- Right Column - Same as Cashier Dashboard -->
<div class="col-lg-3 col-md-4">
<!-- Current Display Status - Smaller and Compact -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-desktop me-2"></i>Display Status
</h6>
</div>
<div class="card-body p-3">
<div class="d-flex flex-column">
<!-- Video Status -->
<div class="text-center mb-3">
<div class="d-flex flex-column align-items-center">
<i class="fas fa-video text-primary mb-1" style="font-size: 1.5rem;"></i>
<small class="text-primary fw-bold" id="video-status-text">Stopped</small>
<small class="text-muted">Video Status</small>
</div>
</div>
<!-- Overlay Status -->
<div class="text-center">
<div class="d-flex flex-column align-items-center">
<i class="fas fa-layer-group text-success mb-1" style="font-size: 1.5rem;"></i>
<small class="text-success fw-bold" id="overlay-status-text">Ready</small>
<small class="text-muted">Overlay Status</small>
</div>
</div>
</div>
<div class="small">
<div class="d-flex justify-content-between mb-1">
<span class="text-muted">Video:</span>
<small id="current-video-path" class="text-truncate" style="max-width: 120px;">No video loaded</small>
</div>
<div class="d-flex justify-content-between">
<span class="text-muted">Template:</span>
<small id="current-template-name">default</small>
</div>
</div>
</div>
</div>
<!-- Session Information -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Info
</h6>
</div>
<div class="card-body p-3">
<dl class="mb-0 small">
<dt class="text-muted">User</dt>
<dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt>
<dd>
<span class="badge bg-info">Cashier</span>
</dd>
<dt class="text-muted">Login Time</dt>
<dd id="login-time">{{ current_user.last_login.strftime('%H:%M') if current_user.last_login else 'Just now' }}</dd>
<dt class="text-muted">System Status</dt>
<dd>
<span class="badge bg-success" id="system-status">Online</span>
</dd>
</dl>
</div>
</div>
<!-- Today's Betting Activity -->
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-chart-bar me-2"></i>Today's Betting
</h6>
</div>
<div class="card-body p-3 text-center">
<div class="row">
<div class="col-6">
<h5 class="text-primary mb-1" id="total-bets">0</h5>
<small class="text-muted">Total Bets</small>
</div>
<div class="col-6">
<h5 class="text-success mb-1" id="total-amount">$0.00</h5>
<small class="text-muted">Total Amount</small>
</div>
</div>
<div class="row mt-2">
<div class="col-4">
<h6 class="text-success mb-1" id="won-bets">0</h6>
<small class="text-muted">Won</small>
</div>
<div class="col-4">
<h6 class="text-danger mb-1" id="lost-bets">0</h6>
<small class="text-muted">Lost</small>
</div>
<div class="col-4">
<h6 class="text-warning mb-1" id="pending-bets">0</h6>
<small class="text-muted">Pending</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load bets on page load
loadBets();
// Date picker change event
document.getElementById('bet-date-picker').addEventListener('change', function() {
loadBets();
});
// New bet button
document.getElementById('btn-new-bet').addEventListener('click', function() {
window.location.href = '/cashier/bets/new';
});
// Status update functions (same as cashier dashboard)
function updateVideoStatus() {
fetch('/api/video/status')
.then(response => response.json())
.then(data => {
const status = data.player_status || 'stopped';
document.getElementById('video-status-text').textContent =
status.charAt(0).toUpperCase() + status.slice(1);
})
.catch(error => {
document.getElementById('video-status-text').textContent = 'Unknown';
});
}
// Initial status update
updateVideoStatus();
// Periodic status updates
setInterval(updateVideoStatus, 5000); // Every 5 seconds
setInterval(loadBets, 10000); // Auto-refresh bets every 10 seconds
});
// Function to load and display bets
function loadBets() {
console.log('🔍 loadBets() called');
const container = document.getElementById('bets-container');
const countBadge = document.getElementById('bets-count');
const dateInput = document.getElementById('bet-date-picker');
const selectedDate = dateInput.value;
if (!container) {
console.error('❌ bets-container not found');
return;
}
console.log('📡 Making API request to /api/cashier/bets for date:', selectedDate);
// Show loading state
container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading bets...
</div>
`;
fetch(`/api/cashier/bets?date=${selectedDate}`)
.then(response => {
console.log('📡 API response status:', response.status);
if (!response.ok) {
throw new Error('API request failed: ' + response.status);
}
return response.json();
})
.then(data => {
console.log('📦 API response data:', data);
if (data.success) {
// Update count badge
countBadge.textContent = data.total;
countBadge.className = data.total > 0 ? 'badge bg-info ms-2' : 'badge bg-secondary ms-2';
// Update statistics
updateBettingStats(data.stats);
updateBetsTable(data, container);
} else {
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading bets: ${data.error || 'Unknown error'}
</div>
`;
}
})
.catch(error => {
console.error('❌ Error loading bets:', error);
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading bets: ${error.message}
</div>
`;
});
}
function updateBetsTable(data, container) {
if (data.total === 0) {
container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-info-circle me-2"></i>No bets found for the selected date
<div class="mt-2">
<button class="btn btn-success" onclick="document.getElementById('btn-new-bet').click()">
<i class="fas fa-plus me-2"></i>Create Your First Bet
</button>
</div>
</div>
`;
return;
}
let tableHTML = `
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th><i class="fas fa-hashtag me-1"></i>Bet ID</th>
<th><i class="fas fa-clock me-1"></i>Date & Time</th>
<th><i class="fas fa-list-ol me-1"></i>Details</th>
<th><i class="fas fa-dollar-sign me-1"></i>Total Amount</th>
<th><i class="fas fa-chart-line me-1"></i>Status</th>
<th><i class="fas fa-cogs me-1"></i>Actions</th>
</tr>
</thead>
<tbody>
`;
data.bets.forEach(bet => {
const betDateTime = new Date(bet.bet_datetime).toLocaleString();
const totalAmount = parseFloat(bet.total_amount).toFixed(2);
// Determine overall bet status based on details
let overallStatus = 'pending';
let statusBadge = '';
if (bet.details && bet.details.length > 0) {
const statuses = bet.details.map(detail => detail.result);
if (statuses.every(status => status === 'won')) {
overallStatus = 'won';
statusBadge = '<span class="badge bg-success"><i class="fas fa-trophy me-1"></i>Won</span>';
} else if (statuses.some(status => status === 'lost')) {
overallStatus = 'lost';
statusBadge = '<span class="badge bg-danger"><i class="fas fa-times me-1"></i>Lost</span>';
} else if (statuses.some(status => status === 'cancelled')) {
overallStatus = 'cancelled';
statusBadge = '<span class="badge bg-secondary"><i class="fas fa-ban me-1"></i>Cancelled</span>';
} else {
statusBadge = '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
}
} else {
statusBadge = '<span class="badge bg-warning"><i class="fas fa-clock me-1"></i>Pending</span>';
}
tableHTML += `
<tr>
<td><strong>${bet.uuid.substring(0, 8)}...</strong></td>
<td>${betDateTime}</td>
<td>${bet.details ? bet.details.length : 0} selections</td>
<td><strong>$${totalAmount}</strong></td>
<td>${statusBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="window.location.href='/cashier/bets/${bet.uuid}'"
title="View Details">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-outline-secondary ms-1 btn-print-bet"
data-bet-id="${bet.uuid}"
title="Print Bet Receipt">
<i class="fas fa-print"></i>
</button>
${overallStatus === 'pending' ? `
<button class="btn btn-sm btn-outline-danger ms-1 btn-cancel-bet"
data-bet-id="${bet.uuid}"
title="Cancel Bet">
<i class="fas fa-ban"></i>
</button>
` : ''}
</td>
</tr>
`;
});
tableHTML += `
</tbody>
</table>
</div>
`;
container.innerHTML = tableHTML;
// Add event listeners for cancel buttons
container.querySelectorAll('.btn-cancel-bet').forEach(button => {
button.addEventListener('click', function() {
const betId = this.getAttribute('data-bet-id');
if (confirm('Are you sure you want to cancel this bet? This action cannot be undone.')) {
cancelBet(betId);
}
});
});
// Add event listeners for print buttons
container.querySelectorAll('.btn-print-bet').forEach(button => {
button.addEventListener('click', function() {
const betId = this.getAttribute('data-bet-id');
printBet(betId);
});
});
}
function updateBettingStats(stats) {
if (!stats) return;
document.getElementById('total-bets').textContent = stats.total_bets || 0;
document.getElementById('total-amount').textContent = '$' + (parseFloat(stats.total_amount || 0).toFixed(2));
document.getElementById('won-bets').textContent = stats.won_bets || 0;
document.getElementById('lost-bets').textContent = stats.lost_bets || 0;
document.getElementById('pending-bets').textContent = stats.pending_bets || 0;
}
function cancelBet(betId) {
fetch(`/api/cashier/bets/${betId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Refresh the bets table
loadBets();
showNotification('Bet cancelled successfully!', 'success');
} else {
showNotification('Failed to cancel bet: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(error => {
showNotification('Error cancelling bet: ' + error.message, 'error');
});
}
function printBet(betId) {
// Placeholder function for printing bet receipt
// This will be implemented later with actual print functionality
console.log('Print bet requested for bet ID:', betId);
showNotification('Print functionality will be implemented soon!', 'info');
// TODO: Implement actual print functionality
// This could involve:
// 1. Opening a new window with a formatted receipt
// 2. Calling a print API endpoint
// 3. Generating a PDF for printing
}
function showNotification(message, type = 'info') {
// Simple notification system - could be enhanced with toast notifications
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-danger' : 'alert-info';
const notification = document.createElement('div');
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
</script>
<!-- Include the main dashboard.js for timer functionality -->
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script>
// Initialize dashboard with timer functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize Dashboard with timer support
if (typeof Dashboard !== 'undefined') {
Dashboard.init({
user: {
username: '{{ current_user.username }}',
role: 'cashier'
}
});
}
});
</script>
{% endblock %}
\ No newline at end of file
...@@ -27,8 +27,8 @@ ...@@ -27,8 +27,8 @@
</button> </button>
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<button class="btn btn-outline-primary w-100" id="btn-play-video"> <button class="btn btn-success w-100 fw-bold" id="btn-bets">
<i class="fas fa-play me-2"></i>Start Video Display <i class="fas fa-coins me-2"></i>Bets
</button> </button>
</div> </div>
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
...@@ -36,11 +36,6 @@ ...@@ -36,11 +36,6 @@
<i class="fas fa-edit me-2"></i>Update Display Overlay <i class="fas fa-edit me-2"></i>Update Display Overlay
</button> </button>
</div> </div>
<div class="col-md-4 mb-3">
<button class="btn btn-outline-info w-100" id="btn-refresh-matches">
<i class="fas fa-sync-alt me-2"></i>Refresh Matches
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
...@@ -162,41 +157,6 @@ ...@@ -162,41 +157,6 @@
</div> </div>
</div> </div>
<!-- Video Control Modal -->
<div class="modal fade" id="playVideoModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Start Video Display</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="play-video-form">
<div class="mb-3">
<label class="form-label">Video File Path</label>
<input type="text" class="form-control" id="video-file-path"
placeholder="/path/to/video.mp4">
<div class="form-text">Enter the full path to the video file you want to display</div>
</div>
<div class="mb-3">
<label class="form-label">Display Template</label>
<select class="form-select" id="video-template">
<option value="">Loading templates...</option>
</select>
<div class="form-text">Choose the template for displaying information over the video</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="confirm-play-video">
<i class="fas fa-play me-1"></i>Start Display
</button>
</div>
</div>
</div>
</div>
<!-- Overlay Update Modal --> <!-- Overlay Update Modal -->
<div class="modal fade" id="updateOverlayModal" tabindex="-1"> <div class="modal fade" id="updateOverlayModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
...@@ -277,59 +237,15 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -277,59 +237,15 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
document.getElementById('btn-play-video').addEventListener('click', function() { document.getElementById('btn-bets').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('playVideoModal')).show(); window.location.href = '/cashier/bets';
}); });
document.getElementById('btn-update-overlay').addEventListener('click', function() { document.getElementById('btn-update-overlay').addEventListener('click', function() {
new bootstrap.Modal(document.getElementById('updateOverlayModal')).show(); new bootstrap.Modal(document.getElementById('updateOverlayModal')).show();
}); });
document.getElementById('btn-refresh-matches').addEventListener('click', function() {
console.log('🔄 Manual refresh button clicked');
loadPendingMatches();
});
// Confirm actions // Confirm actions
document.getElementById('confirm-play-video').addEventListener('click', function() {
const filePath = document.getElementById('video-file-path').value;
const template = document.getElementById('video-template').value;
if (!filePath) {
alert('Please enter a video file path');
return;
}
fetch('/api/video/control', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'play',
file_path: filePath,
template: template
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getInstance(document.getElementById('playVideoModal')).hide();
updateVideoStatus();
updateCurrentVideoPath(filePath);
updateCurrentTemplate(template || 'default');
// Increment counter
const videosCount = parseInt(document.getElementById('videos-played').textContent) + 1;
document.getElementById('videos-played').textContent = videosCount;
} else {
alert('Failed to start video: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
document.getElementById('confirm-update-overlay').addEventListener('click', function() { document.getElementById('confirm-update-overlay').addEventListener('click', function() {
const template = document.getElementById('overlay-template').value; const template = document.getElementById('overlay-template').value;
const headline = document.getElementById('overlay-headline').value; const headline = document.getElementById('overlay-headline').value;
...@@ -632,21 +548,13 @@ function loadAvailableTemplates() { ...@@ -632,21 +548,13 @@ function loadAvailableTemplates() {
fetch('/api/templates') fetch('/api/templates')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template'); const overlayTemplateSelect = document.getElementById('overlay-template');
// Clear loading options // Clear loading options
videoTemplateSelect.innerHTML = '';
overlayTemplateSelect.innerHTML = ''; overlayTemplateSelect.innerHTML = '';
if (data.templates && Array.isArray(data.templates)) { if (data.templates && Array.isArray(data.templates)) {
data.templates.forEach(template => { data.templates.forEach(template => {
// Add to video template select
const videoOption = document.createElement('option');
videoOption.value = template.name;
videoOption.textContent = template.display_name || template.name;
videoTemplateSelect.appendChild(videoOption);
// Add to overlay template select // Add to overlay template select
const overlayOption = document.createElement('option'); const overlayOption = document.createElement('option');
overlayOption.value = template.name; overlayOption.value = template.name;
...@@ -655,22 +563,12 @@ function loadAvailableTemplates() { ...@@ -655,22 +563,12 @@ function loadAvailableTemplates() {
}); });
// Select default template if available // Select default template if available
const defaultVideoOption = videoTemplateSelect.querySelector('option[value="default"]');
if (defaultVideoOption) {
defaultVideoOption.selected = true;
}
const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]'); const defaultOverlayOption = overlayTemplateSelect.querySelector('option[value="default"]');
if (defaultOverlayOption) { if (defaultOverlayOption) {
defaultOverlayOption.selected = true; defaultOverlayOption.selected = true;
} }
} else { } else {
// Fallback if no templates found // Fallback if no templates found
const videoOption = document.createElement('option');
videoOption.value = 'default';
videoOption.textContent = 'Default';
videoTemplateSelect.appendChild(videoOption);
const overlayOption = document.createElement('option'); const overlayOption = document.createElement('option');
overlayOption.value = 'default'; overlayOption.value = 'default';
overlayOption.textContent = 'Default'; overlayOption.textContent = 'Default';
...@@ -680,10 +578,7 @@ function loadAvailableTemplates() { ...@@ -680,10 +578,7 @@ function loadAvailableTemplates() {
.catch(error => { .catch(error => {
console.error('Error loading templates:', error); console.error('Error loading templates:', error);
// Fallback template options // Fallback template options
const videoTemplateSelect = document.getElementById('video-template');
const overlayTemplateSelect = document.getElementById('overlay-template'); const overlayTemplateSelect = document.getElementById('overlay-template');
videoTemplateSelect.innerHTML = '<option value="default">Default</option>';
overlayTemplateSelect.innerHTML = '<option value="default">Default</option>'; overlayTemplateSelect.innerHTML = '<option value="default">Default</option>';
}); });
} }
......
...@@ -188,6 +188,7 @@ ...@@ -188,6 +188,7 @@
cursor: move; cursor: move;
user-select: none; user-select: none;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative;
} }
.extraction-outcome:hover { .extraction-outcome:hover {
...@@ -201,6 +202,65 @@ ...@@ -201,6 +202,65 @@
transform: rotate(5deg); transform: rotate(5deg);
} }
/* Visual indicators for association counts */
.extraction-outcome.has-associations {
border-left: 4px solid #28a745;
}
.extraction-outcome.max-associations {
border-left: 4px solid #ffc107;
background-color: #fff3cd;
}
.extraction-outcome .association-count {
position: absolute;
top: -8px;
right: -8px;
background-color: #007bff;
color: white;
border-radius: 50%;
width: 18px;
height: 18px;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.extraction-outcome.max-associations .association-count {
background-color: #ffc107;
color: #000;
}
/* Column outcome styling - different from pool outcomes */
.extraction-outcome.in-column {
background-color: #e7f3ff;
border-color: #007bff;
position: relative;
}
.extraction-outcome.in-column:hover {
background-color: #cce7ff;
}
.extraction-outcome.in-column .remove-btn {
position: absolute;
top: -5px;
right: -5px;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #dc3545;
color: white;
border: none;
font-size: 10px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.trash-bin { .trash-bin {
border: 2px dashed #dc3545; border: 2px dashed #dc3545;
border-radius: 0.375rem; border-radius: 0.375rem;
...@@ -219,6 +279,11 @@ ...@@ -219,6 +279,11 @@
background-color: #e7f3ff; background-color: #e7f3ff;
} }
.extraction-column.drop-invalid {
border-color: #dc3545;
background-color: #f8d7da;
}
.trash-bin.drop-target { .trash-bin.drop-target {
border-color: #dc3545; border-color: #dc3545;
background-color: #f5c6cb; background-color: #f5c6cb;
...@@ -229,6 +294,7 @@ ...@@ -229,6 +294,7 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let draggedElement = null; let draggedElement = null;
let draggedOutcome = null; let draggedOutcome = null;
let currentAssociations = []; // Track current associations
// Load data on page load // Load data on page load
loadAvailableOutcomes(); loadAvailableOutcomes();
...@@ -243,6 +309,18 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -243,6 +309,18 @@ document.addEventListener('DOMContentLoaded', function() {
// Drag and drop functionality // Drag and drop functionality
setupDragAndDrop(); setupDragAndDrop();
// Click handler for individual remove buttons in columns
document.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-btn')) {
const outcomeElement = e.target.closest('.extraction-outcome');
const outcomeName = outcomeElement.dataset.outcome;
const column = e.target.closest('.extraction-column');
const extractionResult = column.dataset.result;
removeSpecificAssociation(outcomeName, extractionResult);
}
});
function setupDragAndDrop() { function setupDragAndDrop() {
// Make outcomes draggable // Make outcomes draggable
document.addEventListener('dragstart', function(e) { document.addEventListener('dragstart', function(e) {
...@@ -268,7 +346,28 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -268,7 +346,28 @@ document.addEventListener('DOMContentLoaded', function() {
e.target.closest('.extraction-column') || e.target.closest('.extraction-column') ||
e.target.closest('.trash-bin')) { e.target.closest('.trash-bin')) {
e.preventDefault(); e.preventDefault();
e.target.classList.add('drop-target');
// Check if this drop would be valid for columns
let target = e.target;
if (e.target.closest('.extraction-column')) {
target = e.target.closest('.extraction-column');
}
if (target.classList.contains('extraction-column') && draggedOutcome) {
const extractionResult = target.dataset.result;
const wouldExceedLimit = getOutcomeAssociationCount(draggedOutcome) >= 2;
const alreadyAssociated = isOutcomeAssociatedWithResult(draggedOutcome, extractionResult);
if (wouldExceedLimit && !alreadyAssociated) {
target.classList.add('drop-invalid');
target.classList.remove('drop-target');
} else if (!alreadyAssociated) {
target.classList.add('drop-target');
target.classList.remove('drop-invalid');
}
} else {
target.classList.add('drop-target');
}
} }
}); });
...@@ -276,6 +375,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -276,6 +375,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (e.target.classList.contains('extraction-column') || if (e.target.classList.contains('extraction-column') ||
e.target.classList.contains('trash-bin')) { e.target.classList.contains('trash-bin')) {
e.target.classList.remove('drop-target'); e.target.classList.remove('drop-target');
e.target.classList.remove('drop-invalid');
} }
}); });
...@@ -287,8 +387,9 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -287,8 +387,9 @@ document.addEventListener('DOMContentLoaded', function() {
e.preventDefault(); e.preventDefault();
// Remove drop target styling // Remove drop target styling
document.querySelectorAll('.drop-target').forEach(el => { document.querySelectorAll('.drop-target, .drop-invalid').forEach(el => {
el.classList.remove('drop-target'); el.classList.remove('drop-target');
el.classList.remove('drop-invalid');
}); });
if (!draggedOutcome) return; if (!draggedOutcome) return;
...@@ -302,17 +403,27 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -302,17 +403,27 @@ document.addEventListener('DOMContentLoaded', function() {
} }
if (target.classList.contains('extraction-column')) { if (target.classList.contains('extraction-column')) {
// Move to extraction result column // Add to extraction result column
const extractionResult = target.dataset.result; const extractionResult = target.dataset.result;
moveOutcomeToResult(draggedOutcome, extractionResult); addOutcomeToResult(draggedOutcome, extractionResult);
} else if (target.classList.contains('trash-bin')) { } else if (target.classList.contains('trash-bin')) {
// Remove association // Remove all associations for this outcome
removeOutcomeAssociation(draggedOutcome); removeAllOutcomeAssociations(draggedOutcome);
} }
} }
}); });
} }
function getOutcomeAssociationCount(outcomeName) {
return currentAssociations.filter(assoc => assoc.outcome_name === outcomeName).length;
}
function isOutcomeAssociatedWithResult(outcomeName, extractionResult) {
return currentAssociations.some(assoc =>
assoc.outcome_name === outcomeName && assoc.extraction_result === extractionResult
);
}
function loadAvailableOutcomes() { function loadAvailableOutcomes() {
fetch('/api/extraction/outcomes') fetch('/api/extraction/outcomes')
.then(response => response.json()) .then(response => response.json())
...@@ -345,6 +456,12 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -345,6 +456,12 @@ document.addEventListener('DOMContentLoaded', function() {
<small>These outcomes appear above the main extraction area with time limits.</small> <small>These outcomes appear above the main extraction area with time limits.</small>
</div> </div>
`; `;
underOverOutcomes.forEach(outcome => {
const outcomeElement = createOutcomeElement(outcome);
underOverSection.appendChild(outcomeElement);
});
pool.appendChild(underOverSection); pool.appendChild(underOverSection);
} }
...@@ -358,11 +475,7 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -358,11 +475,7 @@ document.addEventListener('DOMContentLoaded', function() {
`; `;
regularOutcomes.forEach(outcome => { regularOutcomes.forEach(outcome => {
const outcomeElement = document.createElement('div'); const outcomeElement = createOutcomeElement(outcome);
outcomeElement.className = 'extraction-outcome';
outcomeElement.draggable = true;
outcomeElement.dataset.outcome = outcome;
outcomeElement.textContent = outcome;
regularSection.appendChild(outcomeElement); regularSection.appendChild(outcomeElement);
}); });
...@@ -380,11 +493,39 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -380,11 +493,39 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
function createOutcomeElement(outcome) {
const outcomeElement = document.createElement('div');
outcomeElement.className = 'extraction-outcome';
outcomeElement.draggable = true;
outcomeElement.dataset.outcome = outcome;
outcomeElement.textContent = outcome;
// Add visual indicators based on association count
const associationCount = getOutcomeAssociationCount(outcome);
if (associationCount > 0) {
outcomeElement.classList.add('has-associations');
if (associationCount >= 2) {
outcomeElement.classList.add('max-associations');
}
// Add association count badge
const countBadge = document.createElement('div');
countBadge.className = 'association-count';
countBadge.textContent = associationCount;
outcomeElement.appendChild(countBadge);
}
return outcomeElement;
}
function loadCurrentAssociations() { function loadCurrentAssociations() {
fetch('/api/extraction/associations') fetch('/api/extraction/associations')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Store current associations for validation
currentAssociations = data.associations;
// Clear existing associations from columns // Clear existing associations from columns
document.querySelectorAll('.extraction-column').forEach(column => { document.querySelectorAll('.extraction-column').forEach(column => {
const placeholder = column.querySelector('.text-center'); const placeholder = column.querySelector('.text-center');
...@@ -403,6 +544,9 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -403,6 +544,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Update associations display // Update associations display
updateAssociationsDisplay(data.associations); updateAssociationsDisplay(data.associations);
// Refresh outcomes pool with updated association counts
loadAvailableOutcomes();
} else { } else {
document.getElementById('associations-display').innerHTML = document.getElementById('associations-display').innerHTML =
'<div class="alert alert-danger">Failed to load associations</div>'; '<div class="alert alert-danger">Failed to load associations</div>';
...@@ -419,33 +563,51 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -419,33 +563,51 @@ document.addEventListener('DOMContentLoaded', function() {
const column = document.querySelector(`[data-result="${extractionResult}"]`); const column = document.querySelector(`[data-result="${extractionResult}"]`);
if (!column) return; if (!column) return;
// Remove placeholder if present // Remove placeholder if present (only for first outcome in column)
const placeholder = column.querySelector('.text-center'); const placeholder = column.querySelector('.text-center');
if (placeholder) { if (placeholder && column.children.length === 1) {
placeholder.remove(); placeholder.remove();
} }
// Add outcome element // Create outcome element with remove button
const outcomeElement = document.createElement('div'); const outcomeElement = document.createElement('div');
outcomeElement.className = 'extraction-outcome'; outcomeElement.className = 'extraction-outcome in-column';
outcomeElement.draggable = true; outcomeElement.draggable = true;
outcomeElement.dataset.outcome = outcomeName; outcomeElement.dataset.outcome = outcomeName;
outcomeElement.textContent = outcomeName; outcomeElement.textContent = outcomeName;
// Add remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.innerHTML = '×';
removeBtn.title = `Remove ${outcomeName} from ${extractionResult}`;
outcomeElement.appendChild(removeBtn);
column.appendChild(outcomeElement); column.appendChild(outcomeElement);
} }
function moveOutcomeToResult(outcomeName, extractionResult) { function addOutcomeToResult(outcomeName, extractionResult) {
// Save association to server // Check if association already exists
fetch('/api/extraction/associations', { if (isOutcomeAssociatedWithResult(outcomeName, extractionResult)) {
alert(`Outcome '${outcomeName}' is already associated with ${extractionResult}`);
return;
}
// Check association limit
if (getOutcomeAssociationCount(outcomeName) >= 2) {
alert(`Outcome '${outcomeName}' already has the maximum 2 associations`);
return;
}
// Add association using new endpoint
fetch('/api/extraction/associations/add', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
associations: [{ outcome_name: outcomeName,
outcome_name: outcomeName, extraction_result: extractionResult
extraction_result: extractionResult
}]
}) })
}) })
.then(response => response.json()) .then(response => response.json())
...@@ -453,48 +615,34 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -453,48 +615,34 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.success) { if (data.success) {
// Reload associations to reflect changes // Reload associations to reflect changes
loadCurrentAssociations(); loadCurrentAssociations();
loadAvailableOutcomes(); // Refresh available outcomes
} else { } else {
alert('Failed to save association: ' + (data.error || 'Unknown error')); alert('Failed to add association: ' + (data.error || 'Unknown error'));
} }
}) })
.catch(error => { .catch(error => {
console.error('Error saving association:', error); console.error('Error adding association:', error);
alert('Error saving association: ' + error.message); alert('Error adding association: ' + error.message);
}); });
} }
function removeOutcomeAssociation(outcomeName) { function removeSpecificAssociation(outcomeName, extractionResult) {
// For now, we'll need to get all associations and remove the specific one // Remove specific association using new endpoint
// In a full implementation, you might want a specific delete endpoint fetch('/api/extraction/associations/remove', {
fetch('/api/extraction/associations') method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
outcome_name: outcomeName,
extraction_result: extractionResult
})
})
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
// Filter out the association to remove loadCurrentAssociations();
const updatedAssociations = data.associations.filter( } else {
assoc => assoc.outcome_name !== outcomeName alert('Failed to remove association: ' + (data.error || 'Unknown error'));
);
// Save updated associations
fetch('/api/extraction/associations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
associations: updatedAssociations
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
loadCurrentAssociations();
loadAvailableOutcomes();
} else {
alert('Failed to remove association: ' + (data.error || 'Unknown error'));
}
});
} }
}) })
.catch(error => { .catch(error => {
...@@ -503,6 +651,49 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -503,6 +651,49 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
function removeAllOutcomeAssociations(outcomeName) {
// Get all associations for this outcome and remove them one by one
const outcomeAssociations = currentAssociations.filter(assoc => assoc.outcome_name === outcomeName);
if (outcomeAssociations.length === 0) {
alert(`No associations found for outcome '${outcomeName}'`);
return;
}
// Remove each association
let removedCount = 0;
const totalToRemove = outcomeAssociations.length;
outcomeAssociations.forEach(assoc => {
fetch('/api/extraction/associations/remove', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
outcome_name: assoc.outcome_name,
extraction_result: assoc.extraction_result
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
removedCount++;
if (removedCount === totalToRemove) {
// All associations removed, reload
loadCurrentAssociations();
}
} else {
alert('Failed to remove association: ' + (data.error || 'Unknown error'));
}
})
.catch(error => {
console.error('Error removing association:', error);
alert('Error removing association: ' + error.message);
});
});
}
function updateAssociationsDisplay(associations) { function updateAssociationsDisplay(associations) {
const display = document.getElementById('associations-display'); const display = document.getElementById('associations-display');
......
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-plus-circle me-2"></i>Create New Bet
<small class="text-muted">Select matches and outcomes</small>
</h1>
</div>
</div>
<!-- Back Button and Submit Button -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-gamepad me-2"></i>Available Matches for Betting
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<button class="btn btn-outline-secondary" onclick="window.location.href='/cashier/bets'">
<i class="fas fa-arrow-left me-2"></i>Back to Bets
</button>
</div>
<div class="col-md-6 mb-3 text-end">
<button class="btn btn-success" id="btn-submit-bet" disabled>
<i class="fas fa-check me-2"></i>Submit Bet
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content Layout -->
<div class="row">
<!-- Available Matches - Left Side -->
<div class="col-lg-9 col-md-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="card-title mb-0">
<i class="fas fa-list me-2"></i>Today's Matches Available for Betting
<span class="badge bg-success ms-2" id="available-matches-count">0</span>
</h5>
</div>
<div class="card-body">
<div id="available-matches-container">
<div class="text-center text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>Loading available matches...
</div>
</div>
</div>
</div>
</div>
<!-- Right Column - Current Bet Summary -->
<div class="col-lg-3 col-md-4">
<!-- Current Bet Summary -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-receipt me-2"></i>Bet Summary
</h6>
</div>
<div class="card-body p-3">
<div id="bet-summary-content">
<div class="text-center text-muted">
<i class="fas fa-info-circle me-2"></i>
<small>Select outcomes to start building your bet</small>
</div>
</div>
<div class="border-top pt-3 mt-3" id="bet-total-section" style="display: none;">
<div class="d-flex justify-content-between">
<strong>Total Amount:</strong>
<strong class="text-success" id="bet-total-amount">$0.00</strong>
</div>
</div>
</div>
</div>
<!-- Session Information -->
<div class="card mb-4">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-user me-2"></i>Session Info
</h6>
</div>
<div class="card-body p-3">
<dl class="mb-0 small">
<dt class="text-muted">User</dt>
<dd>{{ current_user.username }}</dd>
<dt class="text-muted">Role</dt>
<dd>
<span class="badge bg-info">Cashier</span>
</dd>
<dt class="text-muted">Date</dt>
<dd id="current-date">{{ current_date }}</dd>
</dl>
</div>
</div>
<!-- Quick Betting Tips -->
<div class="card">
<div class="card-header">
<h6 class="card-title mb-0">
<i class="fas fa-lightbulb me-2"></i>Betting Tips
</h6>
</div>
<div class="card-body p-3">
<ul class="small mb-0 list-unstyled">
<li class="mb-2">
<i class="fas fa-check-circle text-success me-2"></i>
Select multiple outcomes for combination bets
</li>
<li class="mb-2">
<i class="fas fa-dollar-sign text-info me-2"></i>
Enter amounts with 2 decimal precision
</li>
<li class="mb-2">
<i class="fas fa-eye text-warning me-2"></i>
Review your selections before submitting
</li>
<li>
<i class="fas fa-ban text-danger me-2"></i>
Only pending bets can be cancelled
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load available matches on page load
loadAvailableMatches();
// Submit bet button
document.getElementById('btn-submit-bet').addEventListener('click', function() {
submitBet();
});
});
let selectedOutcomes = new Map(); // matchId -> { outcomes: [], amounts: [] }
// Function to load and display available matches for betting
function loadAvailableMatches() {
console.log('🔍 loadAvailableMatches() called');
const container = document.getElementById('available-matches-container');
const countBadge = document.getElementById('available-matches-count');
if (!container) {
console.error('❌ available-matches-container not found');
return;
}
console.log('📡 Making API request to /api/cashier/available-matches');
fetch('/api/cashier/available-matches')
.then(response => {
console.log('📡 API response status:', response.status);
if (!response.ok) {
throw new Error('API request failed: ' + response.status);
}
return response.json();
})
.then(data => {
console.log('📦 API response data:', data);
if (data.success) {
// Update count badge
countBadge.textContent = data.total;
countBadge.className = data.total > 0 ? 'badge bg-success ms-2' : 'badge bg-warning ms-2';
updateAvailableMatchesDisplay(data, container);
} else {
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${data.error || 'Unknown error'}
</div>
`;
}
})
.catch(error => {
console.error('❌ Error loading matches:', error);
container.innerHTML = `
<div class="text-center text-danger">
<i class="fas fa-exclamation-triangle me-2"></i>Error loading matches: ${error.message}
</div>
`;
});
}
function generateOutcomeOptionsHTML(match, matchId) {
// Use actual outcomes from the database if available, otherwise fallback to defaults
let outcomes = match.outcomes || [];
// If no outcomes from database, use standard fallback
if (outcomes.length === 0) {
console.warn(`No outcomes found for match ${matchId}, using standard fallback`);
outcomes = [
{ outcome_name: 'WIN1', display_name: `WIN1 - ${match.fighter1_township}`, outcome_id: null },
{ outcome_name: 'WIN2', display_name: `WIN2 - ${match.fighter2_township}`, outcome_id: null },
{ outcome_name: 'X', display_name: 'X - Draw', outcome_id: null }
];
}
let outcomesHTML = '';
const colors = ['border-success text-success', 'border-danger text-danger', 'border-warning text-warning', 'border-info text-info', 'border-primary text-primary'];
outcomes.forEach((outcome, index) => {
const colorClass = colors[index % colors.length];
const outcomeName = outcome.outcome_name;
const displayName = outcome.display_name || outcome.outcome_name;
outcomesHTML += `
<div class="col-md-4 mb-3">
<div class="card ${colorClass.split(' ')[0]}">
<div class="card-body text-center p-3">
<div class="form-check mb-2">
<input class="form-check-input outcome-checkbox" type="checkbox"
id="outcome-${matchId}-${outcomeName}"
data-match-id="${matchId}"
data-outcome="${outcomeName}">
<label class="form-check-label fw-bold ${colorClass.split(' ')[1]}" for="outcome-${matchId}-${outcomeName}">
${displayName}
</label>
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" class="form-control amount-input"
id="amount-${matchId}-${outcomeName}"
placeholder="0.00" step="0.01" min="0.01" disabled>
</div>
</div>
</div>
</div>
`;
});
return outcomesHTML;
}
function updateAvailableMatchesDisplay(data, container) {
if (data.total === 0) {
container.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-info-circle me-2"></i>No matches available for betting today
<div class="mt-2">
<small>Matches must be in 'bet' status to accept wagers</small>
</div>
</div>
`;
return;
}
let htmlContent = '';
data.matches.forEach(match => {
const matchId = match.id;
const startTime = match.start_time ? new Date(match.start_time).toLocaleString() : 'TBD';
htmlContent += `
<div class="card mb-3 match-card" data-match-id="${matchId}">
<div class="card-header bg-light">
<div class="row align-items-center">
<div class="col-md-8">
<h6 class="mb-0">
<i class="fas fa-fist-raised me-2 text-primary"></i>
Match #${match.match_number}: ${match.fighter1_township} vs ${match.fighter2_township}
</h6>
<small class="text-muted">
<i class="fas fa-map-marker-alt me-1"></i>${match.venue_kampala_township}
<i class="fas fa-clock me-1"></i>${startTime}
</small>
</div>
<div class="col-md-4 text-end">
<button class="btn btn-outline-primary btn-sm toggle-match" data-match-id="${matchId}">
<i class="fas fa-chevron-down me-1"></i>Select Outcomes
</button>
</div>
</div>
</div>
<div class="card-body match-outcomes" id="outcomes-${matchId}" style="display: none;">
<div class="row">
<div class="col-12">
<h6 class="text-primary mb-3">
<i class="fas fa-target me-2"></i>Available Betting Outcomes
</h6>
</div>
</div>
<div class="row">
${generateOutcomeOptionsHTML(match, matchId)}
</div>
</div>
</div>
`;
});
container.innerHTML = htmlContent;
// Add event listeners for toggle buttons
container.querySelectorAll('.toggle-match').forEach(button => {
button.addEventListener('click', function() {
const matchId = this.getAttribute('data-match-id');
const outcomesDiv = document.getElementById(`outcomes-${matchId}`);
const icon = this.querySelector('i');
if (outcomesDiv.style.display === 'none') {
outcomesDiv.style.display = 'block';
icon.className = 'fas fa-chevron-up me-1';
this.innerHTML = '<i class="fas fa-chevron-up me-1"></i>Hide Outcomes';
} else {
outcomesDiv.style.display = 'none';
icon.className = 'fas fa-chevron-down me-1';
this.innerHTML = '<i class="fas fa-chevron-down me-1"></i>Select Outcomes';
}
});
});
// Add event listeners for outcome checkboxes
container.querySelectorAll('.outcome-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const matchId = this.getAttribute('data-match-id');
const outcome = this.getAttribute('data-outcome');
const amountInput = document.getElementById(`amount-${matchId}-${outcome}`);
if (this.checked) {
amountInput.disabled = false;
amountInput.focus();
} else {
amountInput.disabled = true;
amountInput.value = '';
updateBetSummary();
}
});
});
// Add event listeners for amount inputs
container.querySelectorAll('.amount-input').forEach(input => {
input.addEventListener('input', function() {
updateBetSummary();
});
});
}
function updateBetSummary() {
const summaryContent = document.getElementById('bet-summary-content');
const totalSection = document.getElementById('bet-total-section');
const totalAmountElement = document.getElementById('bet-total-amount');
const submitButton = document.getElementById('btn-submit-bet');
// Clear previous selections
selectedOutcomes.clear();
let totalAmount = 0;
let hasSelections = false;
let summaryHTML = '';
// Collect all checked outcomes with amounts
document.querySelectorAll('.outcome-checkbox:checked').forEach(checkbox => {
const matchId = checkbox.getAttribute('data-match-id');
const outcome = checkbox.getAttribute('data-outcome');
const amountInput = document.getElementById(`amount-${matchId}-${outcome}`);
const amount = parseFloat(amountInput.value) || 0;
if (amount > 0) {
hasSelections = true;
totalAmount += amount;
// Store selection
if (!selectedOutcomes.has(matchId)) {
selectedOutcomes.set(matchId, { outcomes: [], amounts: [] });
}
const matchSelections = selectedOutcomes.get(matchId);
matchSelections.outcomes.push(outcome);
matchSelections.amounts.push(amount);
// Get match info for display
const matchCard = checkbox.closest('.match-card');
const matchTitle = matchCard.querySelector('h6').textContent.trim();
summaryHTML += `
<div class="mb-2 p-2 bg-light rounded">
<small class="fw-bold d-block">${matchTitle.split(':')[1]}</small>
<small class="text-primary">${outcome}</small>
<div class="text-end">
<strong class="text-success">$${amount.toFixed(2)}</strong>
</div>
</div>
`;
}
});
if (hasSelections) {
summaryContent.innerHTML = summaryHTML;
totalSection.style.display = 'block';
totalAmountElement.textContent = '$' + totalAmount.toFixed(2);
submitButton.disabled = false;
} else {
summaryContent.innerHTML = `
<div class="text-center text-muted">
<i class="fas fa-info-circle me-2"></i>
<small>Select outcomes to start building your bet</small>
</div>
`;
totalSection.style.display = 'none';
submitButton.disabled = true;
}
}
function submitBet() {
if (selectedOutcomes.size === 0) {
showNotification('Please select at least one outcome with an amount', 'error');
return;
}
// Prepare bet data
const betData = {
bet_details: []
};
selectedOutcomes.forEach((selections, matchId) => {
selections.outcomes.forEach((outcome, index) => {
betData.bet_details.push({
match_id: parseInt(matchId),
outcome: outcome,
amount: selections.amounts[index]
});
});
});
console.log('📤 Submitting bet data:', betData);
// Submit to API
fetch('/api/cashier/bets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(betData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showNotification('Bet submitted successfully!', 'success');
setTimeout(() => {
window.location.href = `/cashier/bets/${data.bet_id}`;
}, 1500);
} else {
showNotification('Failed to submit bet: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(error => {
showNotification('Error submitting bet: ' + error.message, 'error');
});
}
function showNotification(message, type = 'info') {
// Simple notification system - could be enhanced with toast notifications
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-danger' : 'alert-info';
const notification = document.createElement('div');
notification.className = `alert ${alertClass} alert-dismissible fade show position-fixed`;
notification.style.top = '20px';
notification.style.right = '20px';
notification.style.zIndex = '9999';
notification.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
</script>
<!-- Include the main dashboard.js for timer functionality -->
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script>
// Initialize dashboard with timer functionality
document.addEventListener('DOMContentLoaded', function() {
// Initialize Dashboard with timer support
if (typeof Dashboard !== 'undefined') {
Dashboard.init({
user: {
username: '{{ current_user.username }}',
role: 'cashier'
}
});
}
});
</script>
{% endblock %}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment