Initial commit - Fire alarm management application

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 21:57:25 -05:00
commit 892ac3d23b
24 changed files with 4183 additions and 0 deletions

722
API.md Normal file
View File

@@ -0,0 +1,722 @@
# API Documentation
The Romanoff Fire Alarm Management System provides a RESTful API for programmatic access to all data. All API endpoints are prefixed with `/api`.
## Base URL
```
http://localhost:5000/api
```
## Response Format
All API responses are JSON. Successful responses return the requested data directly. Error responses include appropriate HTTP status codes.
## Endpoints
### Dashboard Statistics
#### Get Dashboard Stats
```
GET /api/stats
```
Returns aggregated statistics for the dashboard.
**Response**
```json
{
"total_jobs": 25,
"total_budget": 1500000.00,
"total_labor": 450000.00,
"total_material": 350000.00,
"avg_completion": 65.5,
"completed_jobs": 8,
"in_progress_jobs": 15,
"not_started_jobs": 2,
"vendor_budgets": {
"Vendor A": 750000.00,
"Vendor B": 500000.00,
"Unknown": 250000.00
},
"pm_jobs": {
"John Smith": {
"count": 10,
"budget": 600000.00
},
"Jane Doe": {
"count": 8,
"budget": 500000.00
}
},
"jobs_completion": [
{
"id": 1,
"job_number": "JOB-001",
"name": "Project Alpha",
"completion": 75.0,
"budget": 50000.00,
"pm_assigned": "John Smith"
}
]
}
```
---
### Jobs
#### List All Jobs
```
GET /api/jobs
```
Returns a list of all jobs.
**Response**
```json
[
{
"id": 1,
"job_number": "JOB-001",
"job_name": "Project Alpha",
"location": "123 Main St, Raleigh, NC",
"percent_complete": 0.75,
"est_starting_qtr": "Q1 2025",
"fire_alarm_budget": 50000.00,
"labor_estimate": 20000.00,
"material_estimate": 15000.00,
"amount_left_on_contract": 15000.00,
"pm_assigned": "John Smith",
"aor": "Region A",
"fire_vendor": "Vendor A",
"install_partner": "Partner Inc",
"ps_or_install": "Install",
"subcontractor": "Sub Co",
"pci": "Yes",
"voip_or_phone": "VoIP",
"plans": "Approved",
"notes": "Project notes here",
"issues": "No major issues",
"milestone_1": "Permits obtained",
"milestone_2": "Rough-in complete",
"milestone_3": null,
"milestone_4": null,
"milestone_5": null,
"milestone_6": null,
"milestone_7": null,
"elevator_final": "2025-03-15",
"pretest": "2025-04-01",
"final_date": "2025-04-15",
"co_drop_dead_date": "2025-04-30",
"number_of_units": 150,
"sep_club_house": "Yes",
"created_at": "2025-01-01T00:00:00",
"updated_at": "2025-01-15T12:30:00"
}
]
```
#### Get Single Job
```
GET /api/jobs/<id>
```
Returns a single job by ID.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| id | integer | Job ID (path parameter) |
**Response**
Returns a job object (same structure as list item above).
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Job not found |
#### Create Job
```
POST /api/jobs
```
Creates a new job.
**Request Body**
```json
{
"job_number": "JOB-002",
"job_name": "Project Beta",
"location": "456 Oak Ave, Raleigh, NC",
"percent_complete": 0,
"est_starting_qtr": "Q2 2025",
"fire_alarm_budget": 75000.00,
"labor_estimate": 30000.00,
"material_estimate": 25000.00,
"amount_left_on_contract": 75000.00,
"pm_assigned": "Jane Doe",
"aor": "Region B",
"fire_vendor": "Vendor B",
"install_partner": "Partner Corp",
"ps_or_install": "PS",
"subcontractor": "Sub LLC",
"pci": "No",
"voip_or_phone": "Phone",
"plans": "Pending",
"notes": "New project",
"issues": "",
"milestone_1": "Site survey",
"number_of_units": 200,
"sep_club_house": "No",
"elevator_final": "2025-06-15",
"pretest": "2025-07-01",
"final_date": "2025-07-15",
"co_drop_dead_date": "2025-07-31"
}
```
**Required Fields**
| Field | Type | Description |
|-------|------|-------------|
| job_number | string | Unique job identifier |
| job_name | string | Job name/title |
**Optional Fields**
| Field | Type | Description |
|-------|------|-------------|
| location | string | Job location address |
| percent_complete | float | Completion percentage (0.0 - 1.0) |
| est_starting_qtr | string | Estimated starting quarter |
| fire_alarm_budget | float | Total fire alarm budget |
| labor_estimate | float | Labor cost estimate |
| material_estimate | float | Material cost estimate |
| amount_left_on_contract | float | Remaining contract amount |
| pm_assigned | string | Project manager name |
| aor | string | Area of responsibility |
| fire_vendor | string | Fire alarm vendor |
| install_partner | string | Installation partner |
| ps_or_install | string | PS or Install designation |
| subcontractor | string | Subcontractor name |
| pci | string | PCI status |
| voip_or_phone | string | Communication type |
| plans | string | Plans status |
| notes | string | General notes |
| issues | string | Known issues |
| milestone_1 - milestone_7 | string | Project milestones |
| elevator_final | date | Elevator final date (YYYY-MM-DD) |
| pretest | date | Pretest date (YYYY-MM-DD) |
| final_date | date | Final date (YYYY-MM-DD) |
| co_drop_dead_date | date | CO drop dead date (YYYY-MM-DD) |
| number_of_units | integer | Number of units |
| sep_club_house | string | Separate club house indicator |
**Response**
Returns the created job object with status `201 Created`.
#### Update Job
```
PUT /api/jobs/<id>
```
Updates an existing job.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| id | integer | Job ID (path parameter) |
**Request Body**
Include only the fields you want to update. Same fields as create.
```json
{
"percent_complete": 0.85,
"notes": "Updated project notes"
}
```
**Response**
Returns the updated job object.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Job not found |
#### Delete Job
```
DELETE /api/jobs/<id>
```
Deletes a job and all associated phases and materials.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| id | integer | Job ID (path parameter) |
**Response**
Returns `204 No Content` on success.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Job not found |
---
### Phases
#### List Phases for Job
```
GET /api/jobs/<job_id>/phases
```
Returns all phases for a specific job.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| job_id | integer | Job ID (path parameter) |
**Response**
```json
[
{
"id": 1,
"job_id": 1,
"phase_type": "rough_in",
"phase_number": 1,
"points": 500,
"mobilization_date": "2025-02-01",
"start_date": "2025-02-05",
"due_date": "2025-02-28",
"completion_date": "2025-02-25",
"men_on_site": 5,
"completed": true,
"due_date_met": true,
"pci_man_hours": 120.5,
"rer_fire_mgmt_hours": 40.0,
"rer_fire_mgmt_hours_avl": 50.0
}
]
```
#### Create Phase
```
POST /api/jobs/<job_id>/phases
```
Creates a new phase for a job.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| job_id | integer | Job ID (path parameter) |
**Request Body**
```json
{
"phase_type": "trim",
"phase_number": 2,
"points": 300,
"mobilization_date": "2025-03-01",
"start_date": "2025-03-05",
"due_date": "2025-03-31",
"men_on_site": 4,
"completed": false,
"pci_man_hours": 0,
"rer_fire_mgmt_hours": 0,
"rer_fire_mgmt_hours_avl": 30.0
}
```
**Fields**
| Field | Type | Description |
|-------|------|-------------|
| phase_type | string | Phase type: `rough_in`, `trim`, `commissioning`, `final`, `turnover` |
| phase_number | integer | Phase sequence number |
| points | integer | Points assigned to phase |
| mobilization_date | date | Mobilization date (YYYY-MM-DD) |
| start_date | date | Start date (YYYY-MM-DD) |
| due_date | date | Due date (YYYY-MM-DD) |
| completion_date | date | Completion date (YYYY-MM-DD) |
| men_on_site | integer | Number of workers on site |
| completed | boolean | Whether phase is complete |
| due_date_met | boolean | Whether due date was met |
| pci_man_hours | float | PCI man hours |
| rer_fire_mgmt_hours | float | RER fire management hours |
| rer_fire_mgmt_hours_avl | float | RER fire management hours available |
**Response**
Returns the created phase object with status `201 Created`.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Job not found |
#### Update Phase
```
PUT /api/phases/<phase_id>
```
Updates an existing phase.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| phase_id | integer | Phase ID (path parameter) |
**Request Body**
Include only the fields you want to update.
```json
{
"completed": true,
"completion_date": "2025-03-28",
"due_date_met": true
}
```
**Response**
Returns the updated phase object.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Phase not found |
#### Delete Phase
```
DELETE /api/phases/<phase_id>
```
Deletes a phase.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| phase_id | integer | Phase ID (path parameter) |
**Response**
Returns `204 No Content` on success.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Phase not found |
---
### Materials
#### List All Materials
```
GET /api/materials
```
Returns all materials across all jobs.
**Response**
```json
[
{
"id": 1,
"job_id": 1,
"part_number": "FA-SENSOR-001",
"quantity": 50,
"ordered": true,
"received": true,
"received_qty": 50,
"received_by": "John Doe",
"delivered_qty": 45,
"delivered_to": "Site A - Building 1"
}
]
```
#### List Materials for Job
```
GET /api/jobs/<job_id>/materials
```
Returns all materials for a specific job.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| job_id | integer | Job ID (path parameter) |
**Response**
Returns an array of material objects (same structure as above).
#### Create Material
```
POST /api/jobs/<job_id>/materials
```
Creates a new material record for a job.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| job_id | integer | Job ID (path parameter) |
**Request Body**
```json
{
"part_number": "FA-PANEL-002",
"quantity": 10,
"ordered": false,
"received": false,
"received_qty": 0,
"received_by": "",
"delivered_qty": 0,
"delivered_to": ""
}
```
**Fields**
| Field | Type | Description |
|-------|------|-------------|
| part_number | string | Part/item number |
| quantity | integer | Quantity needed |
| ordered | boolean | Whether order has been placed |
| received | boolean | Whether items have been received |
| received_qty | integer | Quantity received |
| received_by | string | Person who received items |
| delivered_qty | integer | Quantity delivered to site |
| delivered_to | string | Delivery location |
**Response**
Returns the created material object with status `201 Created`.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Job not found |
#### Update Material
```
PUT /api/materials/<material_id>
```
Updates an existing material record.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| material_id | integer | Material ID (path parameter) |
**Request Body**
Include only the fields you want to update.
```json
{
"ordered": true,
"received": true,
"received_qty": 10,
"received_by": "Jane Smith"
}
```
**Response**
Returns the updated material object.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Material not found |
#### Delete Material
```
DELETE /api/materials/<material_id>
```
Deletes a material record.
**Parameters**
| Name | Type | Description |
|------|------|-------------|
| material_id | integer | Material ID (path parameter) |
**Response**
Returns `204 No Content` on success.
**Errors**
| Status | Description |
|--------|-------------|
| 404 | Material not found |
---
## Data Types
### Date Format
All dates use ISO 8601 format: `YYYY-MM-DD`
Examples:
- `2025-01-15`
- `2025-12-31`
### DateTime Format
All timestamps use ISO 8601 format: `YYYY-MM-DDTHH:MM:SS`
Examples:
- `2025-01-15T14:30:00`
- `2025-12-31T23:59:59`
### Phase Types
Valid values for `phase_type`:
- `rough_in` - Initial rough-in phase
- `trim` - Trim phase
- `commissioning` - System commissioning
- `final` - Final inspection
- `turnover` - Project turnover
---
## Error Handling
The API uses standard HTTP status codes:
| Status | Description |
|--------|-------------|
| 200 | Success |
| 201 | Created |
| 204 | No Content (successful deletion) |
| 400 | Bad Request (invalid input) |
| 404 | Not Found |
| 500 | Internal Server Error |
---
## Examples
### cURL Examples
**Get all jobs:**
```bash
curl http://localhost:5000/api/jobs
```
**Create a job:**
```bash
curl -X POST http://localhost:5000/api/jobs \
-H "Content-Type: application/json" \
-d '{"job_number": "JOB-003", "job_name": "New Project"}'
```
**Update a job:**
```bash
curl -X PUT http://localhost:5000/api/jobs/1 \
-H "Content-Type: application/json" \
-d '{"percent_complete": 0.5}'
```
**Delete a job:**
```bash
curl -X DELETE http://localhost:5000/api/jobs/1
```
### JavaScript Fetch Examples
**Get all jobs:**
```javascript
const response = await fetch('/api/jobs');
const jobs = await response.json();
```
**Create a job:**
```javascript
const response = await fetch('/api/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job_number: 'JOB-003',
job_name: 'New Project'
})
});
const newJob = await response.json();
```
**Update a job:**
```javascript
const response = await fetch('/api/jobs/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ percent_complete: 0.5 })
});
const updatedJob = await response.json();
```
**Delete a job:**
```javascript
await fetch('/api/jobs/1', { method: 'DELETE' });
```

417
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,417 @@
# Development Guide
This guide covers the architecture, code organization, and contribution guidelines for the Romanoff Fire Alarm Management System.
## Architecture Overview
### Application Structure
```
romanoff/
├── run.py # Entry point - starts Flask dev server
├── import_data.py # Data import utility
├── requirements.txt # Dependencies
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models.py # SQLAlchemy models
│ ├── routes.py # Web routes & API endpoints
│ ├── templates/ # Jinja2 templates
│ └── static/ # CSS, JavaScript assets
└── instance/ # SQLite database (auto-created)
```
### Design Patterns
**Flask App Factory**
The application uses the factory pattern in `app/__init__.py`:
```python
def create_app():
app = Flask(__name__)
# Configure app
db.init_app(app)
# Register blueprints
app.register_blueprint(main_bp)
app.register_blueprint(api_bp, url_prefix='/api')
return app
```
**Blueprint Organization**
Routes are organized into two blueprints:
- `main_bp` - Web routes serving HTML pages
- `api_bp` - REST API endpoints (prefixed with `/api`)
**Model-View Separation**
- Models (`models.py`) handle data persistence
- Routes (`routes.py`) handle request/response logic
- Templates (`templates/`) handle presentation
### Data Flow
```
User Browser
Flask Routes (routes.py)
SQLAlchemy Models (models.py)
SQLite Database (instance/romanoff.db)
```
## Database Schema
### Entity Relationship
```
┌─────────────┐
│ Job │
├─────────────┤
│ id (PK) │
│ job_number │───────┐
│ job_name │ │
│ ... │ │
└─────────────┘ │
│ │
│ 1:N │ 1:N
↓ ↓
┌─────────────┐ ┌─────────────┐
│ Phase │ │ Material │
├─────────────┤ ├─────────────┤
│ id (PK) │ │ id (PK) │
│ job_id (FK) │ │ job_id (FK) │
│ phase_type │ │ part_number │
│ ... │ │ ... │
└─────────────┘ └─────────────┘
```
### Models
**Job** - Core project entity
- Contains budget, team assignments, milestones, dates
- Has many phases and materials (cascade delete)
**Phase** - Project schedule phases
- Types: rough_in, trim, commissioning, final, turnover
- Tracks dates, completion status, man hours
**Material** - Inventory tracking
- Tracks ordering, receipt, and delivery
## Frontend Architecture
### Template Hierarchy
```
base.html
├── dashboard.html - Charts and statistics
├── jobs.html - Job list with filters
├── job_detail.html - Single job view
├── job_form.html - Create/edit job
├── schedule.html - Phase management
└── materials.html - Material tracking
```
### JavaScript Strategy
**Shared Utilities** (`static/js/app.js`)
```javascript
// API helpers
async function apiGet(url) { ... }
async function apiPost(url, data) { ... }
async function apiPut(url, data) { ... }
async function apiDelete(url) { ... }
// Formatting
function formatCurrency(value) { ... }
function formatDate(dateString) { ... }
// UI helpers
function showToast(message, type) { ... }
function getProgressClass(percent) { ... }
function debounce(func, wait) { ... }
function confirmAction(message) { ... }
```
**Page-Specific Scripts**
Each template contains embedded `<script>` blocks for page-specific functionality:
- Data fetching and rendering
- Event handlers
- Filter/search logic
### CSS Organization
**Bootstrap 5** provides the base styling. Custom styles in `static/css/style.css` add:
- Card shadows and hover effects
- Progress bar color states
- Responsive table adjustments
- Form styling enhancements
## Development Setup
### Prerequisites
- Python 3.8+
- pip
### Local Development
1. Create virtual environment:
```bash
python -m venv .venv
source .venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Run development server:
```bash
python run.py
```
4. Access at `http://localhost:5000`
### Database
The SQLite database is automatically created on first run in `instance/romanoff.db`. Tables are created via `db.create_all()` in the app factory.
To reset the database:
```bash
rm instance/romanoff.db
python run.py
```
### Importing Test Data
```bash
python import_data.py
```
Imports from Excel files (if present):
- `Raleigh jobs FIRE ALARM INFORMATION.xlsx`
- `schedule_updated.xlsm`
## Code Guidelines
### Python Style
- Follow PEP 8 style guidelines
- Use type hints where helpful
- Keep functions focused and small
- Document complex logic with comments
### Database Operations
```python
# Reading data
jobs = Job.query.all()
job = Job.query.get_or_404(job_id)
# Creating records
job = Job(job_number='JOB-001', job_name='New Job')
db.session.add(job)
db.session.commit()
# Updating records
job.percent_complete = 0.5
db.session.commit()
# Deleting records
db.session.delete(job)
db.session.commit()
```
### API Response Patterns
```python
# Success with data
return jsonify(job.to_dict())
# Success with created resource
return jsonify(job.to_dict()), 201
# Success with no content
return '', 204
# Error
return jsonify({'error': 'Not found'}), 404
```
### Frontend JavaScript
```javascript
// Fetch pattern
try {
const data = await apiGet('/api/jobs');
renderJobs(data);
} catch (error) {
showToast('Error loading jobs', 'danger');
}
// Event handling
document.getElementById('searchInput')
.addEventListener('input', debounce(filterJobs, 300));
```
## Adding New Features
### Adding a New Model
1. Define model in `models.py`:
```python
class NewModel(db.Model):
__tablename__ = 'new_models'
id = db.Column(db.Integer, primary_key=True)
# ... fields
def to_dict(self):
return { ... }
```
2. Add API routes in `routes.py`
3. Create template if needed
4. Restart server (tables auto-create)
### Adding a New Page
1. Create template in `templates/`
2. Add web route in `routes.py`:
```python
@main_bp.route('/newpage')
def new_page():
return render_template('newpage.html')
```
3. Add navigation link in `base.html`
### Adding API Endpoints
1. Add route function in `routes.py`:
```python
@api_bp.route('/newresource', methods=['GET'])
def get_new_resource():
# ... logic
return jsonify(data)
```
2. Document in `API.md`
## Testing
Currently no automated tests are implemented. Recommended testing approach:
### Manual Testing
- Test all CRUD operations through UI
- Verify API responses with curl or Postman
- Test edge cases (empty data, invalid input)
### Future Testing Setup
```bash
pip install pytest pytest-flask
```
```python
# tests/test_api.py
def test_get_jobs(client):
response = client.get('/api/jobs')
assert response.status_code == 200
```
## Deployment Considerations
### Production Checklist
- [ ] Set `SECRET_KEY` environment variable
- [ ] Configure production database (PostgreSQL recommended)
- [ ] Use production WSGI server (Gunicorn, uWSGI)
- [ ] Enable HTTPS
- [ ] Add authentication/authorization
- [ ] Configure logging
- [ ] Set up database backups
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `SECRET_KEY` | Flask secret key | Yes |
| `DATABASE_URL` | Database connection string | No (defaults to SQLite) |
### Example Production Config
```python
# config.py
import os
class ProductionConfig:
SECRET_KEY = os.environ['SECRET_KEY']
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL')
SQLALCHEMY_TRACK_MODIFICATIONS = False
```
## Contributing
### Workflow
1. Create a feature branch from `main`
2. Make changes following code guidelines
3. Test thoroughly
4. Submit pull request with description
### Commit Messages
Use clear, descriptive commit messages:
- `Add material delivery tracking feature`
- `Fix job completion percentage calculation`
- `Update dashboard chart colors`
### Code Review
Before merging:
- Code follows style guidelines
- No obvious bugs or security issues
- Documentation updated if needed
- Changes tested manually
## Troubleshooting
### Common Issues
**Database locked error**
- Stop any other processes accessing the database
- Delete `.db` file and restart if corrupted
**Import errors**
- Ensure virtual environment is activated
- Run `pip install -r requirements.txt`
**Template not found**
- Check file name matches route
- Verify file is in `templates/` directory
**API returns 404**
- Check URL matches route definition
- Verify blueprint prefix (`/api`)
### Debug Mode
Debug mode is enabled by default in development. Check Flask output for:
- Request/response logging
- SQL queries (if enabled)
- Stack traces on errors
## Resources
- [Flask Documentation](https://flask.palletsprojects.com/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
- [Bootstrap 5 Documentation](https://getbootstrap.com/docs/5.3/)
- [Chart.js Documentation](https://www.chartjs.org/docs/)

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# Romanoff Fire Alarm Management System
A web-based application for managing fire alarm installation projects. Track jobs, budgets, schedules, phases, and materials with an intuitive dashboard interface.
## Features
- **Dashboard** - Overview with charts showing job status, budgets, completion rates, and team assignments
- **Job Management** - Create, edit, and track fire alarm installation projects with comprehensive details
- **Schedule Tracking** - Manage project phases (rough-in, trim, commissioning, final, turnover)
- **Materials Inventory** - Track material procurement, ordering, receipt, and delivery
- **Data Import** - Import project data from Excel spreadsheets
- **REST API** - Full API for programmatic access to all data
## Tech Stack
- **Backend**: Python 3, Flask, SQLAlchemy
- **Database**: SQLite (default)
- **Frontend**: Bootstrap 5, Chart.js, Vanilla JavaScript
- **Data Processing**: Pandas, OpenPyXL
## Installation
### Prerequisites
- Python 3.8 or higher
- pip (Python package manager)
### Setup
1. Clone the repository:
```bash
git clone <repository-url>
cd romanoff
```
2. Create and activate a virtual environment:
```bash
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Run the application:
```bash
python run.py
```
5. Open your browser and navigate to `http://localhost:5000`
## Configuration
The application can be configured using environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | Flask secret key for sessions | `dev-secret-key-change-in-production` |
| `DATABASE_URL` | Database connection string | `sqlite:///romanoff.db` |
## Usage
### Dashboard
The main dashboard provides an at-a-glance view of all projects:
- Summary cards showing total jobs, budget, average completion, and estimates
- Charts for job status distribution, budget by vendor, and completion progress
- Recent jobs table with progress indicators
### Managing Jobs
1. Navigate to **Jobs** in the navigation bar
2. Click **Add New Job** to create a project
3. Fill in project details including budget, team assignments, and milestones
4. View job details by clicking on a job row
5. Edit or delete jobs from the detail view
### Schedule Management
1. Navigate to **Schedule** to view all project phases
2. Filter by job, phase type, or completion status
3. Track mobilization dates, due dates, and men on site
4. Monitor which phases have met their due dates
### Materials Tracking
1. Navigate to **Materials** to view inventory across all jobs
2. Filter by part number, job, or status (pending/ordered/received)
3. Track ordering, receipt, and delivery of materials
4. Update material status inline
### Importing Data
To import data from Excel files:
```bash
python import_data.py
```
This imports from:
- `Raleigh jobs FIRE ALARM INFORMATION.xlsx` - Job records
- `schedule_updated.xlsm` - Schedule/phase data
## Project Structure
```
romanoff/
├── run.py # Application entry point
├── import_data.py # Excel data import utility
├── requirements.txt # Python dependencies
├── README.md # This file
├── API.md # API documentation
├── DEVELOPMENT.md # Developer guide
├── app/
│ ├── __init__.py # Flask app factory
│ ├── models.py # Database models
│ ├── routes.py # Routes and API endpoints
│ ├── templates/ # Jinja2 HTML templates
│ │ ├── base.html
│ │ ├── dashboard.html
│ │ ├── jobs.html
│ │ ├── job_detail.html
│ │ ├── job_form.html
│ │ ├── schedule.html
│ │ └── materials.html
│ └── static/
│ ├── css/
│ │ └── style.css
│ └── js/
│ └── app.js
└── instance/ # Instance-specific data (database)
```
## API
See [API.md](API.md) for complete REST API documentation.
### Quick Reference
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/stats` | GET | Dashboard statistics |
| `/api/jobs` | GET, POST | List/create jobs |
| `/api/jobs/<id>` | GET, PUT, DELETE | Get/update/delete job |
| `/api/jobs/<id>/phases` | GET, POST | List/create phases for job |
| `/api/phases/<id>` | PUT, DELETE | Update/delete phase |
| `/api/materials` | GET | List all materials |
| `/api/jobs/<id>/materials` | GET, POST | List/create materials for job |
| `/api/materials/<id>` | PUT, DELETE | Update/delete material |
## Development
See [DEVELOPMENT.md](DEVELOPMENT.md) for architecture details and contribution guidelines.
## License
[Add license information here]

Binary file not shown.

25
app/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
from flask import Flask
from .models import db
import os
def create_app():
app = Flask(__name__)
# Configuration
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///fire_alarm.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# Initialize extensions
db.init_app(app)
# Register blueprints
from .routes import main_bp, api_bp
app.register_blueprint(main_bp)
app.register_blueprint(api_bp, url_prefix='/api')
# Create tables
with app.app_context():
db.create_all()
return app

Binary file not shown.

Binary file not shown.

Binary file not shown.

182
app/models.py Normal file
View File

@@ -0,0 +1,182 @@
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
db = SQLAlchemy()
class Job(db.Model):
__tablename__ = 'jobs'
id = db.Column(db.Integer, primary_key=True)
job_number = db.Column(db.String(50), unique=True, nullable=False)
job_name = db.Column(db.String(200), nullable=False)
location = db.Column(db.String(300))
percent_complete = db.Column(db.Float, default=0.0)
est_starting_qtr = db.Column(db.String(50))
# Budget fields
fire_alarm_budget = db.Column(db.Float, default=0.0)
labor_estimate = db.Column(db.Float, default=0.0)
material_estimate = db.Column(db.Float, default=0.0)
amount_left_on_contract = db.Column(db.Float, default=0.0)
# Assignment fields
pm_assigned = db.Column(db.String(100))
aor = db.Column(db.String(50))
fire_vendor = db.Column(db.String(100))
install_partner = db.Column(db.String(100))
ps_or_install = db.Column(db.String(50))
subcontractor = db.Column(db.String(100))
pci = db.Column(db.String(50))
# Communication
voip_or_phone = db.Column(db.String(50))
plans = db.Column(db.String(50))
# Notes
notes = db.Column(db.Text)
issues = db.Column(db.Text)
# Milestones
milestone_1 = db.Column(db.String(200))
milestone_2 = db.Column(db.String(200))
milestone_3 = db.Column(db.String(200))
milestone_4 = db.Column(db.String(200))
milestone_5 = db.Column(db.String(200))
milestone_6 = db.Column(db.String(200))
milestone_7 = db.Column(db.String(200))
# Key dates
elevator_final = db.Column(db.Date)
pretest = db.Column(db.Date)
final_date = db.Column(db.Date)
co_drop_dead_date = db.Column(db.Date)
# Other
number_of_units = db.Column(db.Integer)
sep_club_house = db.Column(db.String(50))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
phases = db.relationship('Phase', backref='job', lazy=True, cascade='all, delete-orphan')
materials = db.relationship('Material', backref='job', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'job_number': self.job_number,
'job_name': self.job_name,
'location': self.location,
'percent_complete': self.percent_complete,
'est_starting_qtr': self.est_starting_qtr,
'fire_alarm_budget': self.fire_alarm_budget,
'labor_estimate': self.labor_estimate,
'material_estimate': self.material_estimate,
'amount_left_on_contract': self.amount_left_on_contract,
'pm_assigned': self.pm_assigned,
'aor': self.aor,
'fire_vendor': self.fire_vendor,
'install_partner': self.install_partner,
'ps_or_install': self.ps_or_install,
'subcontractor': self.subcontractor,
'pci': self.pci,
'voip_or_phone': self.voip_or_phone,
'plans': self.plans,
'notes': self.notes,
'issues': self.issues,
'milestone_1': self.milestone_1,
'milestone_2': self.milestone_2,
'milestone_3': self.milestone_3,
'milestone_4': self.milestone_4,
'milestone_5': self.milestone_5,
'milestone_6': self.milestone_6,
'milestone_7': self.milestone_7,
'elevator_final': self.elevator_final.isoformat() if self.elevator_final else None,
'pretest': self.pretest.isoformat() if self.pretest else None,
'final_date': self.final_date.isoformat() if self.final_date else None,
'co_drop_dead_date': self.co_drop_dead_date.isoformat() if self.co_drop_dead_date else None,
'number_of_units': self.number_of_units,
'sep_club_house': self.sep_club_house,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
class Phase(db.Model):
__tablename__ = 'phases'
id = db.Column(db.Integer, primary_key=True)
job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'), nullable=False)
phase_type = db.Column(db.String(50)) # rough_in, trim, commissioning, final, turnover
phase_number = db.Column(db.Integer)
points = db.Column(db.Integer, default=0)
mobilization_date = db.Column(db.Date)
start_date = db.Column(db.Date)
due_date = db.Column(db.Date)
completion_date = db.Column(db.Date)
men_on_site = db.Column(db.Integer, default=0)
completed = db.Column(db.Boolean, default=False)
due_date_met = db.Column(db.Boolean)
pci_man_hours = db.Column(db.Float, default=0.0)
rer_fire_mgmt_hours = db.Column(db.Float, default=0.0)
rer_fire_mgmt_hours_avl = db.Column(db.Float, default=0.0)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'job_id': self.job_id,
'phase_type': self.phase_type,
'phase_number': self.phase_number,
'points': self.points,
'mobilization_date': self.mobilization_date.isoformat() if self.mobilization_date else None,
'start_date': self.start_date.isoformat() if self.start_date else None,
'due_date': self.due_date.isoformat() if self.due_date else None,
'completion_date': self.completion_date.isoformat() if self.completion_date else None,
'men_on_site': self.men_on_site,
'completed': self.completed,
'due_date_met': self.due_date_met,
'pci_man_hours': self.pci_man_hours,
'rer_fire_mgmt_hours': self.rer_fire_mgmt_hours,
'rer_fire_mgmt_hours_avl': self.rer_fire_mgmt_hours_avl,
}
class Material(db.Model):
__tablename__ = 'materials'
id = db.Column(db.Integer, primary_key=True)
job_id = db.Column(db.Integer, db.ForeignKey('jobs.id'), nullable=False)
part_number = db.Column(db.String(100))
quantity = db.Column(db.Integer, default=0)
ordered = db.Column(db.Boolean, default=False)
received = db.Column(db.Boolean, default=False)
received_qty = db.Column(db.Integer, default=0)
received_by = db.Column(db.String(100))
delivered_qty = db.Column(db.Integer, default=0)
delivered_to = db.Column(db.String(200))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'job_id': self.job_id,
'part_number': self.part_number,
'quantity': self.quantity,
'ordered': self.ordered,
'received': self.received,
'received_qty': self.received_qty,
'received_by': self.received_by,
'delivered_qty': self.delivered_qty,
'delivered_to': self.delivered_to,
}

337
app/routes.py Normal file
View File

@@ -0,0 +1,337 @@
from flask import Blueprint, render_template, request, jsonify, redirect, url_for
from .models import db, Job, Phase, Material
from datetime import datetime
from sqlalchemy import func
main_bp = Blueprint('main', __name__)
api_bp = Blueprint('api', __name__)
# ==================== WEB ROUTES ====================
@main_bp.route('/')
def dashboard():
return render_template('dashboard.html')
@main_bp.route('/jobs')
def jobs_list():
return render_template('jobs.html')
@main_bp.route('/jobs/<int:job_id>')
def job_detail(job_id):
job = Job.query.get_or_404(job_id)
return render_template('job_detail.html', job=job)
@main_bp.route('/jobs/new')
def job_new():
return render_template('job_form.html', job=None)
@main_bp.route('/jobs/<int:job_id>/edit')
def job_edit(job_id):
job = Job.query.get_or_404(job_id)
return render_template('job_form.html', job=job)
@main_bp.route('/schedule')
def schedule():
return render_template('schedule.html')
@main_bp.route('/materials')
def materials():
return render_template('materials.html')
# ==================== API ROUTES ====================
# Dashboard Stats
@api_bp.route('/stats')
def get_stats():
jobs = Job.query.all()
total_jobs = len(jobs)
total_budget = sum(j.fire_alarm_budget or 0 for j in jobs)
total_labor = sum(j.labor_estimate or 0 for j in jobs)
total_material = sum(j.material_estimate or 0 for j in jobs)
# Calculate average completion
avg_completion = sum(j.percent_complete or 0 for j in jobs) / total_jobs if total_jobs > 0 else 0
# Jobs by status
completed_jobs = len([j for j in jobs if (j.percent_complete or 0) >= 1.0])
in_progress_jobs = len([j for j in jobs if 0 < (j.percent_complete or 0) < 1.0])
not_started_jobs = len([j for j in jobs if (j.percent_complete or 0) == 0])
# Budget by vendor
vendor_budgets = {}
for job in jobs:
vendor = job.fire_vendor or 'Unknown'
if vendor not in vendor_budgets:
vendor_budgets[vendor] = 0
vendor_budgets[vendor] += job.fire_alarm_budget or 0
# Jobs by PM
pm_jobs = {}
for job in jobs:
pm = job.pm_assigned or 'Unassigned'
if pm not in pm_jobs:
pm_jobs[pm] = {'count': 0, 'budget': 0}
pm_jobs[pm]['count'] += 1
pm_jobs[pm]['budget'] += job.fire_alarm_budget or 0
return jsonify({
'total_jobs': total_jobs,
'total_budget': total_budget,
'total_labor': total_labor,
'total_material': total_material,
'avg_completion': avg_completion * 100,
'completed_jobs': completed_jobs,
'in_progress_jobs': in_progress_jobs,
'not_started_jobs': not_started_jobs,
'vendor_budgets': vendor_budgets,
'pm_jobs': pm_jobs,
'jobs_completion': [
{
'id': j.id,
'job_number': j.job_number,
'name': j.job_name,
'completion': (j.percent_complete or 0) * 100,
'budget': j.fire_alarm_budget or 0,
'pm_assigned': j.pm_assigned
}
for j in jobs
]
})
# Jobs CRUD
@api_bp.route('/jobs', methods=['GET'])
def get_jobs():
jobs = Job.query.all()
return jsonify([job.to_dict() for job in jobs])
@api_bp.route('/jobs/<int:job_id>', methods=['GET'])
def get_job(job_id):
job = Job.query.get_or_404(job_id)
return jsonify(job.to_dict())
@api_bp.route('/jobs', methods=['POST'])
def create_job():
data = request.json
job = Job(
job_number=data.get('job_number'),
job_name=data.get('job_name'),
location=data.get('location'),
percent_complete=data.get('percent_complete', 0),
est_starting_qtr=data.get('est_starting_qtr'),
fire_alarm_budget=data.get('fire_alarm_budget', 0),
labor_estimate=data.get('labor_estimate', 0),
material_estimate=data.get('material_estimate', 0),
amount_left_on_contract=data.get('amount_left_on_contract', 0),
pm_assigned=data.get('pm_assigned'),
aor=data.get('aor'),
fire_vendor=data.get('fire_vendor'),
install_partner=data.get('install_partner'),
ps_or_install=data.get('ps_or_install'),
subcontractor=data.get('subcontractor'),
pci=data.get('pci'),
voip_or_phone=data.get('voip_or_phone'),
plans=data.get('plans'),
notes=data.get('notes'),
issues=data.get('issues'),
milestone_1=data.get('milestone_1'),
milestone_2=data.get('milestone_2'),
milestone_3=data.get('milestone_3'),
milestone_4=data.get('milestone_4'),
milestone_5=data.get('milestone_5'),
milestone_6=data.get('milestone_6'),
milestone_7=data.get('milestone_7'),
number_of_units=data.get('number_of_units'),
sep_club_house=data.get('sep_club_house'),
)
# Parse dates
for date_field in ['elevator_final', 'pretest', 'final_date', 'co_drop_dead_date']:
if data.get(date_field):
try:
setattr(job, date_field, datetime.strptime(data[date_field], '%Y-%m-%d').date())
except ValueError:
pass
db.session.add(job)
db.session.commit()
return jsonify(job.to_dict()), 201
@api_bp.route('/jobs/<int:job_id>', methods=['PUT'])
def update_job(job_id):
job = Job.query.get_or_404(job_id)
data = request.json
# Update fields
for field in ['job_number', 'job_name', 'location', 'percent_complete', 'est_starting_qtr',
'fire_alarm_budget', 'labor_estimate', 'material_estimate', 'amount_left_on_contract',
'pm_assigned', 'aor', 'fire_vendor', 'install_partner', 'ps_or_install',
'subcontractor', 'pci', 'voip_or_phone', 'plans', 'notes', 'issues',
'milestone_1', 'milestone_2', 'milestone_3', 'milestone_4',
'milestone_5', 'milestone_6', 'milestone_7', 'number_of_units', 'sep_club_house']:
if field in data:
setattr(job, field, data[field])
# Parse dates
for date_field in ['elevator_final', 'pretest', 'final_date', 'co_drop_dead_date']:
if date_field in data:
if data[date_field]:
try:
setattr(job, date_field, datetime.strptime(data[date_field], '%Y-%m-%d').date())
except ValueError:
pass
else:
setattr(job, date_field, None)
db.session.commit()
return jsonify(job.to_dict())
@api_bp.route('/jobs/<int:job_id>', methods=['DELETE'])
def delete_job(job_id):
job = Job.query.get_or_404(job_id)
db.session.delete(job)
db.session.commit()
return '', 204
# Phases CRUD
@api_bp.route('/jobs/<int:job_id>/phases', methods=['GET'])
def get_phases(job_id):
phases = Phase.query.filter_by(job_id=job_id).order_by(Phase.phase_type, Phase.phase_number).all()
return jsonify([p.to_dict() for p in phases])
@api_bp.route('/jobs/<int:job_id>/phases', methods=['POST'])
def create_phase(job_id):
Job.query.get_or_404(job_id)
data = request.json
phase = Phase(
job_id=job_id,
phase_type=data.get('phase_type'),
phase_number=data.get('phase_number'),
points=data.get('points', 0),
men_on_site=data.get('men_on_site', 0),
completed=data.get('completed', False),
due_date_met=data.get('due_date_met'),
pci_man_hours=data.get('pci_man_hours', 0),
rer_fire_mgmt_hours=data.get('rer_fire_mgmt_hours', 0),
rer_fire_mgmt_hours_avl=data.get('rer_fire_mgmt_hours_avl', 0),
)
for date_field in ['mobilization_date', 'start_date', 'due_date', 'completion_date']:
if data.get(date_field):
try:
setattr(phase, date_field, datetime.strptime(data[date_field], '%Y-%m-%d').date())
except ValueError:
pass
db.session.add(phase)
db.session.commit()
return jsonify(phase.to_dict()), 201
@api_bp.route('/phases/<int:phase_id>', methods=['PUT'])
def update_phase(phase_id):
phase = Phase.query.get_or_404(phase_id)
data = request.json
for field in ['phase_type', 'phase_number', 'points', 'men_on_site', 'completed',
'due_date_met', 'pci_man_hours', 'rer_fire_mgmt_hours', 'rer_fire_mgmt_hours_avl']:
if field in data:
setattr(phase, field, data[field])
for date_field in ['mobilization_date', 'start_date', 'due_date', 'completion_date']:
if date_field in data:
if data[date_field]:
try:
setattr(phase, date_field, datetime.strptime(data[date_field], '%Y-%m-%d').date())
except ValueError:
pass
else:
setattr(phase, date_field, None)
db.session.commit()
return jsonify(phase.to_dict())
@api_bp.route('/phases/<int:phase_id>', methods=['DELETE'])
def delete_phase(phase_id):
phase = Phase.query.get_or_404(phase_id)
db.session.delete(phase)
db.session.commit()
return '', 204
# Materials CRUD
@api_bp.route('/jobs/<int:job_id>/materials', methods=['GET'])
def get_materials(job_id):
materials = Material.query.filter_by(job_id=job_id).all()
return jsonify([m.to_dict() for m in materials])
@api_bp.route('/materials', methods=['GET'])
def get_all_materials():
materials = Material.query.all()
return jsonify([m.to_dict() for m in materials])
@api_bp.route('/jobs/<int:job_id>/materials', methods=['POST'])
def create_material(job_id):
Job.query.get_or_404(job_id)
data = request.json
material = Material(
job_id=job_id,
part_number=data.get('part_number'),
quantity=data.get('quantity', 0),
ordered=data.get('ordered', False),
received=data.get('received', False),
received_qty=data.get('received_qty', 0),
received_by=data.get('received_by'),
delivered_qty=data.get('delivered_qty', 0),
delivered_to=data.get('delivered_to'),
)
db.session.add(material)
db.session.commit()
return jsonify(material.to_dict()), 201
@api_bp.route('/materials/<int:material_id>', methods=['PUT'])
def update_material(material_id):
material = Material.query.get_or_404(material_id)
data = request.json
for field in ['part_number', 'quantity', 'ordered', 'received', 'received_qty',
'received_by', 'delivered_qty', 'delivered_to']:
if field in data:
setattr(material, field, data[field])
db.session.commit()
return jsonify(material.to_dict())
@api_bp.route('/materials/<int:material_id>', methods=['DELETE'])
def delete_material(material_id):
material = Material.query.get_or_404(material_id)
db.session.delete(material)
db.session.commit()
return '', 204

108
app/static/css/style.css Normal file
View File

@@ -0,0 +1,108 @@
/* Fire Alarm Management App Styles */
body {
background-color: #f8f9fa;
}
.navbar-brand {
font-weight: bold;
}
.card {
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
border: none;
}
.card-header {
background-color: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.125);
font-weight: 600;
}
.table th {
font-weight: 600;
background-color: #f8f9fa;
}
.progress {
border-radius: 0.5rem;
}
.btn-group-sm > .btn {
padding: 0.25rem 0.5rem;
}
/* Dashboard cards */
.card.bg-primary,
.card.bg-success,
.card.bg-info,
.card.bg-warning {
border: none;
}
.card.bg-primary .card-title,
.card.bg-success .card-title,
.card.bg-info .card-title {
opacity: 0.9;
}
/* Form improvements */
.form-control:focus,
.form-select:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
/* Table hover effect */
.table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
/* Badge styles */
.badge {
font-weight: 500;
}
/* Responsive tables */
@media (max-width: 768px) {
.table-responsive {
font-size: 0.875rem;
}
}
/* Chart containers */
canvas {
max-height: 300px;
}
/* Breadcrumb */
.breadcrumb {
background: none;
padding: 0;
}
/* Modal improvements */
.modal-header {
border-bottom: 1px solid #dee2e6;
}
.modal-footer {
border-top: 1px solid #dee2e6;
}
/* Phase cards in schedule */
.card.border-success {
border-width: 2px !important;
}
/* Filter row */
.row.mb-4 .form-control,
.row.mb-4 .form-select {
background-color: #fff;
}
/* Loading spinner */
.spinner-border {
width: 3rem;
height: 3rem;
}

110
app/static/js/app.js Normal file
View File

@@ -0,0 +1,110 @@
// Fire Alarm Management App - Common JavaScript
// Format currency
function formatCurrency(value) {
return '$' + (value || 0).toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
});
}
// Format date
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Get progress bar class based on completion percentage
function getProgressClass(completion) {
if (completion >= 100) return 'bg-success';
if (completion > 50) return 'bg-info';
if (completion > 0) return 'bg-warning';
return 'bg-secondary';
}
// Show toast notification
function showToast(message, type = 'success') {
const toastContainer = document.getElementById('toastContainer') || createToastContainer();
const toast = document.createElement('div');
toast.className = `toast align-items-center text-white bg-${type} border-0`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `
<div class="d-flex">
<div class="toast-body">${message}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
`;
toastContainer.appendChild(toast);
const bsToast = new bootstrap.Toast(toast);
bsToast.show();
toast.addEventListener('hidden.bs.toast', () => toast.remove());
}
function createToastContainer() {
const container = document.createElement('div');
container.id = 'toastContainer';
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
document.body.appendChild(container);
return container;
}
// Confirm dialog
function confirmAction(message) {
return new Promise((resolve) => {
resolve(confirm(message));
});
}
// API helper functions
async function apiGet(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
async function apiPost(url, data) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
async function apiPut(url, data) {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return response.json();
}
async function apiDelete(url) {
const response = await fetch(url, { method: 'DELETE' });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
return true;
}
// Debounce function for search inputs
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

57
app/templates/base.html Normal file
View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Fire Alarm Management{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-fire"></i> Fire Alarm Manager
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}" href="{{ url_for('main.dashboard') }}">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if 'job' in request.endpoint %}active{% endif %}" href="{{ url_for('main.jobs_list') }}">
<i class="bi bi-list-task"></i> Jobs
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.schedule' %}active{% endif %}" href="{{ url_for('main.schedule') }}">
<i class="bi bi-calendar3"></i> Schedule
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.materials' %}active{% endif %}" href="{{ url_for('main.materials') }}">
<i class="bi bi-box-seam"></i> Materials
</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container-fluid py-4">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,350 @@
{% extends "base.html" %}
{% block title %}Dashboard - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-speedometer2"></i> Dashboard</h1>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-briefcase"></i> Total Jobs</h5>
<h2 class="card-text" id="totalJobs">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-currency-dollar"></i> Total Budget</h5>
<h2 class="card-text" id="totalBudget">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-percent"></i> Avg Completion</h5>
<h2 class="card-text" id="avgCompletion">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-tools"></i> Labor + Material</h5>
<h2 class="card-text" id="laborMaterial">-</h2>
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-pie-chart"></i> Jobs by Status
</div>
<div class="card-body">
<canvas id="statusChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-bar-chart"></i> Budget by Vendor
</div>
<div class="card-body">
<canvas id="vendorChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<i class="bi bi-graph-up"></i> Job Completion Progress
</div>
<div class="card-body">
<canvas id="completionChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-person"></i> Jobs by Project Manager
</div>
<div class="card-body">
<canvas id="pmChart"></canvas>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<i class="bi bi-cash-stack"></i> Budget vs Estimates
</div>
<div class="card-body">
<canvas id="budgetChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Recent Jobs Table -->
<div class="row">
<div class="col">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-clock-history"></i> Recent Jobs</span>
<a href="{{ url_for('main.jobs_list') }}" class="btn btn-sm btn-primary">View All</a>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Job #</th>
<th>Name</th>
<th>PM</th>
<th>Budget</th>
<th>Progress</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="recentJobsTable">
<tr>
<td colspan="6" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let statusChart, vendorChart, completionChart, pmChart, budgetChart;
async function loadDashboard() {
try {
const response = await fetch('/api/stats');
const data = await response.json();
// Update summary cards
document.getElementById('totalJobs').textContent = data.total_jobs;
document.getElementById('totalBudget').textContent = formatCurrency(data.total_budget);
document.getElementById('avgCompletion').textContent = data.avg_completion.toFixed(1) + '%';
document.getElementById('laborMaterial').textContent = formatCurrency(data.total_labor + data.total_material);
// Status Pie Chart
const statusCtx = document.getElementById('statusChart').getContext('2d');
if (statusChart) statusChart.destroy();
statusChart = new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['Completed', 'In Progress', 'Not Started'],
datasets: [{
data: [data.completed_jobs, data.in_progress_jobs, data.not_started_jobs],
backgroundColor: ['#198754', '#0d6efd', '#6c757d']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' }
}
}
});
// Vendor Budget Bar Chart
const vendorCtx = document.getElementById('vendorChart').getContext('2d');
if (vendorChart) vendorChart.destroy();
vendorChart = new Chart(vendorCtx, {
type: 'bar',
data: {
labels: Object.keys(data.vendor_budgets),
datasets: [{
label: 'Budget ($)',
data: Object.values(data.vendor_budgets),
backgroundColor: '#0d6efd'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: value => '$' + value.toLocaleString()
}
}
}
}
});
// Completion Progress Bar Chart
const completionCtx = document.getElementById('completionChart').getContext('2d');
if (completionChart) completionChart.destroy();
completionChart = new Chart(completionCtx, {
type: 'bar',
data: {
labels: data.jobs_completion.map(j => j.name),
datasets: [{
label: 'Completion %',
data: data.jobs_completion.map(j => j.completion),
backgroundColor: data.jobs_completion.map(j => {
if (j.completion >= 100) return '#198754';
if (j.completion > 50) return '#0d6efd';
if (j.completion > 0) return '#ffc107';
return '#6c757d';
})
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
x: {
beginAtZero: true,
max: 100,
ticks: {
callback: value => value + '%'
}
}
}
}
});
// PM Jobs Chart
const pmCtx = document.getElementById('pmChart').getContext('2d');
if (pmChart) pmChart.destroy();
pmChart = new Chart(pmCtx, {
type: 'bar',
data: {
labels: Object.keys(data.pm_jobs),
datasets: [{
label: 'Number of Jobs',
data: Object.values(data.pm_jobs).map(p => p.count),
backgroundColor: '#198754'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 }
}
}
}
});
// Budget vs Estimates Chart
const budgetCtx = document.getElementById('budgetChart').getContext('2d');
if (budgetChart) budgetChart.destroy();
budgetChart = new Chart(budgetCtx, {
type: 'doughnut',
data: {
labels: ['Fire Alarm Budget', 'Labor Estimate', 'Material Estimate'],
datasets: [{
data: [data.total_budget, data.total_labor, data.total_material],
backgroundColor: ['#0d6efd', '#198754', '#ffc107']
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': $' + context.raw.toLocaleString();
}
}
}
}
}
});
// Load recent jobs table
loadRecentJobs(data.jobs_completion);
} catch (error) {
console.error('Error loading dashboard:', error);
}
}
function loadRecentJobs(jobs) {
const tbody = document.getElementById('recentJobsTable');
if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No jobs found. <a href="/jobs/new">Add your first job</a></td></tr>';
return;
}
tbody.innerHTML = jobs.slice(0, 10).map(job => `
<tr>
<td>${job.job_number || '-'}</td>
<td>${job.name}</td>
<td>${job.pm_assigned || '-'}</td>
<td>${formatCurrency(job.budget)}</td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar ${getProgressClass(job.completion)}" role="progressbar"
style="width: ${job.completion}%;" aria-valuenow="${job.completion}"
aria-valuemin="0" aria-valuemax="100">
${job.completion.toFixed(0)}%
</div>
</div>
</td>
<td>
<a href="/jobs/${job.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
`).join('');
}
function formatCurrency(value) {
return '$' + (value || 0).toLocaleString('en-US', {minimumFractionDigits: 0, maximumFractionDigits: 0});
}
function getProgressClass(completion) {
if (completion >= 100) return 'bg-success';
if (completion > 50) return 'bg-info';
if (completion > 0) return 'bg-warning';
return 'bg-secondary';
}
document.addEventListener('DOMContentLoaded', loadDashboard);
</script>
{% endblock %}

View File

@@ -0,0 +1,436 @@
{% extends "base.html" %}
{% block title %}{{ job.job_name }} - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('main.jobs_list') }}">Jobs</a></li>
<li class="breadcrumb-item active">{{ job.job_name }}</li>
</ol>
</nav>
<h1>
<i class="bi bi-briefcase"></i> {{ job.job_name }}
<small class="text-muted">#{{ job.job_number }}</small>
</h1>
</div>
<div class="col-auto">
<a href="{{ url_for('main.job_edit', job_id=job.id) }}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Edit
</a>
</div>
</div>
<!-- Progress Bar -->
<div class="row mb-4">
<div class="col">
<div class="card">
<div class="card-body">
<h5>Overall Progress: {{ (job.percent_complete * 100)|round(0) }}%</h5>
<div class="progress" style="height: 30px;">
<div class="progress-bar {{ 'bg-success' if job.percent_complete >= 1 else ('bg-info' if job.percent_complete > 0.5 else ('bg-warning' if job.percent_complete > 0 else 'bg-secondary')) }}"
role="progressbar" style="width: {{ job.percent_complete * 100 }}%;">
{{ (job.percent_complete * 100)|round(0) }}%
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Basic Info -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Basic Information</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th>Location:</th><td>{{ job.location or '-' }}</td></tr>
<tr><th>Est. Starting Qtr:</th><td>{{ job.est_starting_qtr or '-' }}</td></tr>
<tr><th>Number of Units:</th><td>{{ job.number_of_units or '-' }}</td></tr>
<tr><th>Sep Club House:</th><td>{{ job.sep_club_house or '-' }}</td></tr>
</table>
</div>
</div>
</div>
<!-- Budget -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Budget & Estimates</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th>Fire Alarm Budget:</th><td class="text-success">${{ '{:,.2f}'.format(job.fire_alarm_budget or 0) }}</td></tr>
<tr><th>Labor Estimate:</th><td>${{ '{:,.2f}'.format(job.labor_estimate or 0) }}</td></tr>
<tr><th>Material Estimate:</th><td>${{ '{:,.2f}'.format(job.material_estimate or 0) }}</td></tr>
<tr><th>Amount Left:</th><td class="text-warning">${{ '{:,.2f}'.format(job.amount_left_on_contract or 0) }}</td></tr>
</table>
</div>
</div>
</div>
<!-- Assignments -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Assignments</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th>PM Assigned:</th><td>{{ job.pm_assigned or '-' }}</td></tr>
<tr><th>AOR:</th><td>{{ job.aor or '-' }}</td></tr>
<tr><th>Fire Vendor:</th><td>{{ job.fire_vendor or '-' }}</td></tr>
<tr><th>Install Partner:</th><td>{{ job.install_partner or '-' }}</td></tr>
<tr><th>P/S or Install:</th><td>{{ job.ps_or_install or '-' }}</td></tr>
<tr><th>Subcontractor:</th><td>{{ job.subcontractor or '-' }}</td></tr>
<tr><th>PCI:</th><td>{{ job.pci or '-' }}</td></tr>
</table>
</div>
</div>
</div>
<!-- Communication -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Communication & Plans</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th>VOIP/Phone:</th><td>{{ job.voip_or_phone or '-' }}</td></tr>
<tr><th>Plans:</th><td>{{ job.plans or '-' }}</td></tr>
</table>
</div>
</div>
</div>
<!-- Key Dates -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Key Dates</div>
<div class="card-body">
<table class="table table-borderless">
<tr><th>Elevator Final:</th><td>{{ job.elevator_final or '-' }}</td></tr>
<tr><th>Pretest:</th><td>{{ job.pretest or '-' }}</td></tr>
<tr><th>Final:</th><td>{{ job.final_date or '-' }}</td></tr>
<tr><th>C/O Drop Dead:</th><td>{{ job.co_drop_dead_date or '-' }}</td></tr>
</table>
</div>
</div>
</div>
<!-- Milestones -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Milestones</div>
<div class="card-body">
<table class="table table-borderless">
{% for i in range(1, 8) %}
{% set milestone = job['milestone_' ~ i] %}
{% if milestone %}
<tr><th>{{ i }}{{ 'st' if i == 1 else ('nd' if i == 2 else ('rd' if i == 3 else 'th')) }}:</th><td>{{ milestone }}</td></tr>
{% endif %}
{% endfor %}
</table>
</div>
</div>
</div>
<!-- Notes & Issues -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Notes</div>
<div class="card-body">
<p>{{ job.notes or 'No notes' }}</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header {% if job.issues %}bg-warning{% endif %}">Issues</div>
<div class="card-body">
<p>{{ job.issues or 'No issues' }}</p>
</div>
</div>
</div>
</div>
<!-- Phases Section -->
<div class="row mt-4">
<div class="col">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-check"></i> Phases</span>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addPhaseModal">
<i class="bi bi-plus-lg"></i> Add Phase
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Type</th>
<th>Phase #</th>
<th>Points</th>
<th>Start Date</th>
<th>Due Date</th>
<th>Men on Site</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="phasesTable">
<tr><td colspan="8" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Materials Section -->
<div class="row mt-4">
<div class="col">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-box-seam"></i> Materials</span>
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addMaterialModal">
<i class="bi bi-plus-lg"></i> Add Material
</button>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Part #</th>
<th>Qty</th>
<th>Ordered</th>
<th>Received</th>
<th>Received Qty</th>
<th>Delivered Qty</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="materialsTable">
<tr><td colspan="7" class="text-center">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Add Phase Modal -->
<div class="modal fade" id="addPhaseModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Phase</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addPhaseForm">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Phase Type</label>
<select class="form-select" name="phase_type" required>
<option value="rough_in">Rough-in</option>
<option value="trim">Trim</option>
<option value="commissioning">Commissioning</option>
<option value="final">Final</option>
<option value="turnover">Turnover</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Phase Number</label>
<input type="number" class="form-control" name="phase_number" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Points</label>
<input type="number" class="form-control" name="points">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Start Date</label>
<input type="date" class="form-control" name="start_date">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Due Date</label>
<input type="date" class="form-control" name="due_date">
</div>
</div>
<div class="mb-3">
<label class="form-label">Men on Site</label>
<input type="number" class="form-control" name="men_on_site">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add Phase</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Material Modal -->
<div class="modal fade" id="addMaterialModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Material</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addMaterialForm">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Part Number</label>
<input type="text" class="form-control" name="part_number" required>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<input type="number" class="form-control" name="quantity" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Delivered To</label>
<input type="text" class="form-control" name="delivered_to">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Add Material</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const jobId = {{ job.id }};
async function loadPhases() {
try {
const response = await fetch(`/api/jobs/${jobId}/phases`);
const phases = await response.json();
const tbody = document.getElementById('phasesTable');
if (phases.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center">No phases added yet</td></tr>';
return;
}
tbody.innerHTML = phases.map(p => `
<tr>
<td>${p.phase_type.replace('_', ' ').toUpperCase()}</td>
<td>${p.phase_number}</td>
<td>${p.points || '-'}</td>
<td>${p.start_date || '-'}</td>
<td>${p.due_date || '-'}</td>
<td>${p.men_on_site || '-'}</td>
<td>
<span class="badge ${p.completed ? 'bg-success' : 'bg-secondary'}">
${p.completed ? 'Completed' : 'In Progress'}
</span>
</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deletePhase(${p.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading phases:', error);
}
}
async function loadMaterials() {
try {
const response = await fetch(`/api/jobs/${jobId}/materials`);
const materials = await response.json();
const tbody = document.getElementById('materialsTable');
if (materials.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No materials added yet</td></tr>';
return;
}
tbody.innerHTML = materials.map(m => `
<tr>
<td>${m.part_number}</td>
<td>${m.quantity}</td>
<td><i class="bi bi-${m.ordered ? 'check-circle text-success' : 'x-circle text-danger'}"></i></td>
<td><i class="bi bi-${m.received ? 'check-circle text-success' : 'x-circle text-danger'}"></i></td>
<td>${m.received_qty}</td>
<td>${m.delivered_qty}</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMaterial(${m.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Error loading materials:', error);
}
}
document.getElementById('addPhaseForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
await fetch(`/api/jobs/${jobId}/phases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
bootstrap.Modal.getInstance(document.getElementById('addPhaseModal')).hide();
e.target.reset();
loadPhases();
} catch (error) {
console.error('Error adding phase:', error);
}
});
document.getElementById('addMaterialForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
try {
await fetch(`/api/jobs/${jobId}/materials`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
bootstrap.Modal.getInstance(document.getElementById('addMaterialModal')).hide();
e.target.reset();
loadMaterials();
} catch (error) {
console.error('Error adding material:', error);
}
});
async function deletePhase(phaseId) {
if (!confirm('Delete this phase?')) return;
await fetch(`/api/phases/${phaseId}`, { method: 'DELETE' });
loadPhases();
}
async function deleteMaterial(materialId) {
if (!confirm('Delete this material?')) return;
await fetch(`/api/materials/${materialId}`, { method: 'DELETE' });
loadMaterials();
}
document.addEventListener('DOMContentLoaded', () => {
loadPhases();
loadMaterials();
});
</script>
{% endblock %}

316
app/templates/job_form.html Normal file
View File

@@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if job else 'New' }} Job - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1>
<i class="bi bi-{{ 'pencil' if job else 'plus-lg' }}"></i>
{{ 'Edit Job: ' + job.job_name if job else 'New Job' }}
</h1>
</div>
</div>
<form id="jobForm">
<div class="row">
<!-- Basic Info -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Basic Information</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Job Number *</label>
<input type="text" class="form-control" name="job_number" required
value="{{ job.job_number if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Job Name *</label>
<input type="text" class="form-control" name="job_name" required
value="{{ job.job_name if job else '' }}">
</div>
<div class="col-12">
<label class="form-label">Location</label>
<input type="text" class="form-control" name="location"
value="{{ job.location if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Est. Starting Qtr</label>
<input type="text" class="form-control" name="est_starting_qtr"
value="{{ job.est_starting_qtr if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Number of Units</label>
<input type="number" class="form-control" name="number_of_units"
value="{{ job.number_of_units if job else '' }}">
</div>
</div>
</div>
</div>
</div>
<!-- Budget -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Budget & Estimates</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Fire Alarm Budget</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" name="fire_alarm_budget"
value="{{ job.fire_alarm_budget if job else '' }}">
</div>
</div>
<div class="col-md-6">
<label class="form-label">Labor Estimate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" name="labor_estimate"
value="{{ job.labor_estimate if job else '' }}">
</div>
</div>
<div class="col-md-6">
<label class="form-label">Material Estimate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" name="material_estimate"
value="{{ job.material_estimate if job else '' }}">
</div>
</div>
<div class="col-md-6">
<label class="form-label">Amount Left on Contract</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" name="amount_left_on_contract"
value="{{ job.amount_left_on_contract if job else '' }}">
</div>
</div>
<div class="col-12">
<label class="form-label">Completion %</label>
<div class="input-group">
<input type="number" step="0.01" min="0" max="1" class="form-control" name="percent_complete"
value="{{ job.percent_complete if job else '0' }}">
<span class="input-group-text">0-1 (e.g., 0.5 = 50%)</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Assignments -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Assignments</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">PM Assigned</label>
<input type="text" class="form-control" name="pm_assigned"
value="{{ job.pm_assigned if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">AOR</label>
<input type="text" class="form-control" name="aor"
value="{{ job.aor if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Fire Vendor</label>
<input type="text" class="form-control" name="fire_vendor"
value="{{ job.fire_vendor if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Install Partner</label>
<input type="text" class="form-control" name="install_partner"
value="{{ job.install_partner if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">P/S or Install</label>
<select class="form-select" name="ps_or_install">
<option value="">Select...</option>
<option value="P/S" {{ 'selected' if job and job.ps_or_install == 'P/S' else '' }}>P/S</option>
<option value="INSTALL" {{ 'selected' if job and job.ps_or_install == 'INSTALL' else '' }}>Install</option>
<option value="P/S WITH VENDOR - RER INSTALL" {{ 'selected' if job and job.ps_or_install == 'P/S WITH VENDOR - RER INSTALL' else '' }}>P/S with Vendor - RER Install</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Subcontractor</label>
<input type="text" class="form-control" name="subcontractor"
value="{{ job.subcontractor if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">PCI</label>
<input type="text" class="form-control" name="pci"
value="{{ job.pci if job else '' }}">
</div>
</div>
</div>
</div>
</div>
<!-- Communication & Plans -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Communication & Plans</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">VOIP or Phone</label>
<select class="form-select" name="voip_or_phone">
<option value="">Select...</option>
<option value="VOIP" {{ 'selected' if job and job.voip_or_phone == 'VOIP' else '' }}>VOIP</option>
<option value="PHONE" {{ 'selected' if job and job.voip_or_phone == 'PHONE' else '' }}>Phone</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Plans</label>
<select class="form-select" name="plans">
<option value="">Select...</option>
<option value="Yes" {{ 'selected' if job and job.plans == 'Yes' else '' }}>Yes</option>
<option value="No" {{ 'selected' if job and job.plans == 'No' else '' }}>No</option>
<option value="Done" {{ 'selected' if job and job.plans == 'Done' else '' }}>Done</option>
</select>
</div>
<div class="col-md-6">
<label class="form-label">Sep Club House</label>
<select class="form-select" name="sep_club_house">
<option value="">Select...</option>
<option value="YES" {{ 'selected' if job and job.sep_club_house == 'YES' else '' }}>Yes</option>
<option value="NO" {{ 'selected' if job and job.sep_club_house == 'NO' else '' }}>No</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Key Dates -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Key Dates</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Elevator Final</label>
<input type="date" class="form-control" name="elevator_final"
value="{{ job.elevator_final if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Pretest</label>
<input type="date" class="form-control" name="pretest"
value="{{ job.pretest if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">Final Date</label>
<input type="date" class="form-control" name="final_date"
value="{{ job.final_date if job else '' }}">
</div>
<div class="col-md-6">
<label class="form-label">C/O Drop Dead Date</label>
<input type="date" class="form-control" name="co_drop_dead_date"
value="{{ job.co_drop_dead_date if job else '' }}">
</div>
</div>
</div>
</div>
</div>
<!-- Milestones -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">Milestones</div>
<div class="card-body">
<div class="row g-3">
{% for i in range(1, 8) %}
<div class="col-12">
<label class="form-label">{{ i }}{{ 'st' if i == 1 else ('nd' if i == 2 else ('rd' if i == 3 else 'th')) }} Milestone</label>
<input type="text" class="form-control" name="milestone_{{ i }}"
value="{{ job['milestone_' ~ i] if job else '' }}">
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Notes & Issues -->
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header">Notes & Issues</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="4">{{ job.notes if job else '' }}</textarea>
</div>
<div class="col-md-6">
<label class="form-label">Issues</label>
<textarea class="form-control" name="issues" rows="4">{{ job.issues if job else '' }}</textarea>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> {{ 'Update' if job else 'Create' }} Job
</button>
<a href="{{ url_for('main.jobs_list') }}" class="btn btn-secondary">
<i class="bi bi-x-lg"></i> Cancel
</a>
</div>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('jobForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = {};
formData.forEach((value, key) => {
if (value !== '') {
if (['fire_alarm_budget', 'labor_estimate', 'material_estimate',
'amount_left_on_contract', 'percent_complete'].includes(key)) {
data[key] = parseFloat(value) || 0;
} else if (key === 'number_of_units') {
data[key] = parseInt(value) || null;
} else {
data[key] = value;
}
}
});
const jobId = {{ job.id if job else 'null' }};
const method = jobId ? 'PUT' : 'POST';
const url = jobId ? `/api/jobs/${jobId}` : '/api/jobs';
try {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
window.location.href = '/jobs';
} else {
const error = await response.json();
alert('Error saving job: ' + (error.message || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert('Error saving job');
}
});
</script>
{% endblock %}

237
app/templates/jobs.html Normal file
View File

@@ -0,0 +1,237 @@
{% extends "base.html" %}
{% block title %}Jobs - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-list-task"></i> Jobs</h1>
</div>
<div class="col-auto">
<a href="{{ url_for('main.job_new') }}" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> New Job
</a>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-md-3">
<input type="text" id="searchInput" class="form-control" placeholder="Search jobs...">
</div>
<div class="col-md-2">
<select id="statusFilter" class="form-select">
<option value="">All Status</option>
<option value="completed">Completed</option>
<option value="in_progress">In Progress</option>
<option value="not_started">Not Started</option>
</select>
</div>
<div class="col-md-2">
<select id="vendorFilter" class="form-select">
<option value="">All Vendors</option>
</select>
</div>
<div class="col-md-2">
<select id="pmFilter" class="form-select">
<option value="">All PMs</option>
</select>
</div>
</div>
<!-- Jobs Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Job #</th>
<th>Name</th>
<th>Location</th>
<th>PM</th>
<th>Vendor</th>
<th>Budget</th>
<th>Progress</th>
<th>Units</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobsTable">
<tr>
<td colspan="9" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this job?
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDelete">Delete</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allJobs = [];
let deleteJobId = null;
async function loadJobs() {
try {
const response = await fetch('/api/jobs');
allJobs = await response.json();
populateFilters();
renderJobs(allJobs);
} catch (error) {
console.error('Error loading jobs:', error);
document.getElementById('jobsTable').innerHTML =
'<tr><td colspan="9" class="text-center text-danger">Error loading jobs</td></tr>';
}
}
function populateFilters() {
const vendors = [...new Set(allJobs.map(j => j.fire_vendor).filter(v => v))];
const pms = [...new Set(allJobs.map(j => j.pm_assigned).filter(p => p))];
const vendorSelect = document.getElementById('vendorFilter');
vendors.forEach(v => {
const option = document.createElement('option');
option.value = v;
option.textContent = v;
vendorSelect.appendChild(option);
});
const pmSelect = document.getElementById('pmFilter');
pms.forEach(p => {
const option = document.createElement('option');
option.value = p;
option.textContent = p;
pmSelect.appendChild(option);
});
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTable');
if (jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center">No jobs found</td></tr>';
return;
}
tbody.innerHTML = jobs.map(job => {
const completion = (job.percent_complete || 0) * 100;
return `
<tr>
<td><strong>${job.job_number || '-'}</strong></td>
<td>${job.job_name}</td>
<td>${job.location || '-'}</td>
<td>${job.pm_assigned || '-'}</td>
<td>${job.fire_vendor || '-'}</td>
<td>$${(job.fire_alarm_budget || 0).toLocaleString()}</td>
<td style="min-width: 150px;">
<div class="progress" style="height: 20px;">
<div class="progress-bar ${getProgressClass(completion)}" role="progressbar"
style="width: ${completion}%;" aria-valuenow="${completion}"
aria-valuemin="0" aria-valuemax="100">
${completion.toFixed(0)}%
</div>
</div>
</td>
<td>${job.number_of_units || '-'}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/jobs/${job.id}" class="btn btn-outline-primary" title="View">
<i class="bi bi-eye"></i>
</a>
<a href="/jobs/${job.id}/edit" class="btn btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<button class="btn btn-outline-danger" title="Delete" onclick="showDeleteModal(${job.id})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
`;
}).join('');
}
function getProgressClass(completion) {
if (completion >= 100) return 'bg-success';
if (completion > 50) return 'bg-info';
if (completion > 0) return 'bg-warning';
return 'bg-secondary';
}
function filterJobs() {
const search = document.getElementById('searchInput').value.toLowerCase();
const status = document.getElementById('statusFilter').value;
const vendor = document.getElementById('vendorFilter').value;
const pm = document.getElementById('pmFilter').value;
let filtered = allJobs.filter(job => {
const matchSearch = !search ||
(job.job_number && job.job_number.toLowerCase().includes(search)) ||
(job.job_name && job.job_name.toLowerCase().includes(search)) ||
(job.location && job.location.toLowerCase().includes(search));
const completion = (job.percent_complete || 0);
const matchStatus = !status ||
(status === 'completed' && completion >= 1) ||
(status === 'in_progress' && completion > 0 && completion < 1) ||
(status === 'not_started' && completion === 0);
const matchVendor = !vendor || job.fire_vendor === vendor;
const matchPM = !pm || job.pm_assigned === pm;
return matchSearch && matchStatus && matchVendor && matchPM;
});
renderJobs(filtered);
}
function showDeleteModal(jobId) {
deleteJobId = jobId;
new bootstrap.Modal(document.getElementById('deleteModal')).show();
}
async function deleteJob() {
if (!deleteJobId) return;
try {
await fetch(`/api/jobs/${deleteJobId}`, { method: 'DELETE' });
bootstrap.Modal.getInstance(document.getElementById('deleteModal')).hide();
loadJobs();
} catch (error) {
console.error('Error deleting job:', error);
alert('Error deleting job');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadJobs();
document.getElementById('searchInput').addEventListener('input', filterJobs);
document.getElementById('statusFilter').addEventListener('change', filterJobs);
document.getElementById('vendorFilter').addEventListener('change', filterJobs);
document.getElementById('pmFilter').addEventListener('change', filterJobs);
document.getElementById('confirmDelete').addEventListener('click', deleteJob);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,256 @@
{% extends "base.html" %}
{% block title %}Materials - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-box-seam"></i> Materials Tracking</h1>
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">Total Items</h5>
<h2 id="totalItems">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-warning text-dark">
<div class="card-body">
<h5 class="card-title">Pending Order</h5>
<h2 id="pendingOrder">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-info text-white">
<div class="card-body">
<h5 class="card-title">Ordered</h5>
<h2 id="ordered">-</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">Received</h5>
<h2 id="received">-</h2>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-md-3">
<input type="text" id="searchInput" class="form-control" placeholder="Search part number...">
</div>
<div class="col-md-3">
<select id="jobFilter" class="form-select">
<option value="">All Jobs</option>
</select>
</div>
<div class="col-md-3">
<select id="statusFilter" class="form-select">
<option value="">All Status</option>
<option value="pending">Pending Order</option>
<option value="ordered">Ordered</option>
<option value="received">Received</option>
</select>
</div>
</div>
<!-- Materials Table -->
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Job</th>
<th>Part #</th>
<th>Qty Needed</th>
<th>Ordered</th>
<th>Received</th>
<th>Received Qty</th>
<th>Received By</th>
<th>Delivered Qty</th>
<th>Delivered To</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="materialsTable">
<tr>
<td colspan="10" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allMaterials = [];
let allJobs = [];
async function loadData() {
try {
// Load jobs
const jobsResponse = await fetch('/api/jobs');
allJobs = await jobsResponse.json();
// Populate job filter
const jobFilter = document.getElementById('jobFilter');
allJobs.forEach(job => {
const option = document.createElement('option');
option.value = job.id;
option.textContent = `${job.job_number} - ${job.job_name}`;
jobFilter.appendChild(option);
});
// Load all materials
allMaterials = [];
for (const job of allJobs) {
const materialsResponse = await fetch(`/api/jobs/${job.id}/materials`);
const materials = await materialsResponse.json();
materials.forEach(m => {
m.job = job;
allMaterials.push(m);
});
}
updateSummary();
renderMaterials(allMaterials);
} catch (error) {
console.error('Error loading data:', error);
}
}
function updateSummary() {
document.getElementById('totalItems').textContent = allMaterials.length;
document.getElementById('pendingOrder').textContent = allMaterials.filter(m => !m.ordered).length;
document.getElementById('ordered').textContent = allMaterials.filter(m => m.ordered && !m.received).length;
document.getElementById('received').textContent = allMaterials.filter(m => m.received).length;
}
function renderMaterials(materials) {
const tbody = document.getElementById('materialsTable');
if (materials.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center">No materials found</td></tr>';
return;
}
tbody.innerHTML = materials.map(m => `
<tr>
<td>
<a href="/jobs/${m.job.id}">${m.job.job_number}</a>
<br><small class="text-muted">${m.job.job_name}</small>
</td>
<td><strong>${m.part_number}</strong></td>
<td>${m.quantity}</td>
<td>
<div class="form-check">
<input class="form-check-input" type="checkbox" ${m.ordered ? 'checked' : ''}
onchange="updateMaterial(${m.id}, 'ordered', this.checked)">
</div>
</td>
<td>
<div class="form-check">
<input class="form-check-input" type="checkbox" ${m.received ? 'checked' : ''}
onchange="updateMaterial(${m.id}, 'received', this.checked)">
</div>
</td>
<td>
<input type="number" class="form-control form-control-sm" style="width: 80px;"
value="${m.received_qty || 0}"
onchange="updateMaterial(${m.id}, 'received_qty', parseInt(this.value))">
</td>
<td>
<input type="text" class="form-control form-control-sm" style="width: 100px;"
value="${m.received_by || ''}"
onchange="updateMaterial(${m.id}, 'received_by', this.value)">
</td>
<td>
<input type="number" class="form-control form-control-sm" style="width: 80px;"
value="${m.delivered_qty || 0}"
onchange="updateMaterial(${m.id}, 'delivered_qty', parseInt(this.value))">
</td>
<td>${m.delivered_to || '-'}</td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteMaterial(${m.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`).join('');
}
async function updateMaterial(materialId, field, value) {
try {
await fetch(`/api/materials/${materialId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value })
});
// Update local data
const material = allMaterials.find(m => m.id === materialId);
if (material) {
material[field] = value;
}
updateSummary();
} catch (error) {
console.error('Error updating material:', error);
alert('Error updating material');
}
}
async function deleteMaterial(materialId) {
if (!confirm('Delete this material?')) return;
try {
await fetch(`/api/materials/${materialId}`, { method: 'DELETE' });
allMaterials = allMaterials.filter(m => m.id !== materialId);
updateSummary();
filterMaterials();
} catch (error) {
console.error('Error deleting material:', error);
}
}
function filterMaterials() {
const search = document.getElementById('searchInput').value.toLowerCase();
const jobId = document.getElementById('jobFilter').value;
const status = document.getElementById('statusFilter').value;
let filtered = allMaterials.filter(m => {
const matchSearch = !search || (m.part_number && m.part_number.toLowerCase().includes(search));
const matchJob = !jobId || m.job.id == jobId;
const matchStatus = !status ||
(status === 'pending' && !m.ordered) ||
(status === 'ordered' && m.ordered && !m.received) ||
(status === 'received' && m.received);
return matchSearch && matchJob && matchStatus;
});
renderMaterials(filtered);
}
document.addEventListener('DOMContentLoaded', () => {
loadData();
document.getElementById('searchInput').addEventListener('input', filterMaterials);
document.getElementById('jobFilter').addEventListener('change', filterMaterials);
document.getElementById('statusFilter').addEventListener('change', filterMaterials);
});
</script>
{% endblock %}

182
app/templates/schedule.html Normal file
View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Schedule - Fire Alarm Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h1><i class="bi bi-calendar3"></i> Schedule Overview</h1>
</div>
</div>
<!-- Filters -->
<div class="row mb-4">
<div class="col-md-3">
<select id="jobFilter" class="form-select">
<option value="">All Jobs</option>
</select>
</div>
<div class="col-md-3">
<select id="phaseTypeFilter" class="form-select">
<option value="">All Phase Types</option>
<option value="rough_in">Rough-in</option>
<option value="trim">Trim</option>
<option value="commissioning">Commissioning</option>
<option value="final">Final</option>
<option value="turnover">Turnover</option>
</select>
</div>
<div class="col-md-3">
<select id="statusFilter" class="form-select">
<option value="">All Status</option>
<option value="completed">Completed</option>
<option value="in_progress">In Progress</option>
</select>
</div>
</div>
<!-- Schedule Cards by Job -->
<div id="scheduleContainer" class="row">
<div class="col text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allJobs = [];
let allPhases = {};
async function loadSchedule() {
try {
// Load all jobs
const jobsResponse = await fetch('/api/jobs');
allJobs = await jobsResponse.json();
// Populate job filter
const jobFilter = document.getElementById('jobFilter');
allJobs.forEach(job => {
const option = document.createElement('option');
option.value = job.id;
option.textContent = `${job.job_number} - ${job.job_name}`;
jobFilter.appendChild(option);
});
// Load phases for each job
for (const job of allJobs) {
const phasesResponse = await fetch(`/api/jobs/${job.id}/phases`);
allPhases[job.id] = await phasesResponse.json();
}
renderSchedule();
} catch (error) {
console.error('Error loading schedule:', error);
document.getElementById('scheduleContainer').innerHTML =
'<div class="col"><div class="alert alert-danger">Error loading schedule</div></div>';
}
}
function renderSchedule() {
const container = document.getElementById('scheduleContainer');
const jobFilter = document.getElementById('jobFilter').value;
const phaseTypeFilter = document.getElementById('phaseTypeFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
let jobs = jobFilter ? allJobs.filter(j => j.id == jobFilter) : allJobs;
if (jobs.length === 0) {
container.innerHTML = '<div class="col"><div class="alert alert-info">No jobs found</div></div>';
return;
}
container.innerHTML = jobs.map(job => {
let phases = allPhases[job.id] || [];
if (phaseTypeFilter) {
phases = phases.filter(p => p.phase_type === phaseTypeFilter);
}
if (statusFilter) {
phases = phases.filter(p =>
(statusFilter === 'completed' && p.completed) ||
(statusFilter === 'in_progress' && !p.completed)
);
}
if (phases.length === 0 && (phaseTypeFilter || statusFilter)) {
return '';
}
const phaseTypes = ['rough_in', 'trim', 'commissioning', 'final', 'turnover'];
const phaseLabels = {
'rough_in': 'Rough-in',
'trim': 'Trim',
'commissioning': 'Commissioning',
'final': 'Final',
'turnover': 'Turnover'
};
return `
<div class="col-12 mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<strong>${job.job_number}</strong> - ${job.job_name}
<span class="badge bg-info">${(job.percent_complete * 100).toFixed(0)}% Complete</span>
</span>
<a href="/jobs/${job.id}" class="btn btn-sm btn-outline-primary">View Job</a>
</div>
<div class="card-body">
${phases.length === 0 ? '<p class="text-muted">No phases scheduled yet</p>' : `
<div class="row">
${phaseTypes.map(type => {
const typePhases = phases.filter(p => p.phase_type === type);
if (typePhases.length === 0) return '';
return `
<div class="col-md-4 mb-3">
<h6 class="text-uppercase text-muted">${phaseLabels[type]}</h6>
${typePhases.map(p => `
<div class="card mb-2 ${p.completed ? 'border-success' : ''}">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-center">
<span>Phase ${p.phase_number}</span>
<span class="badge ${p.completed ? 'bg-success' : 'bg-secondary'}">
${p.completed ? 'Done' : 'Pending'}
</span>
</div>
<small class="text-muted">
${p.points ? p.points + ' pts' : ''}
${p.start_date ? ' | Start: ' + p.start_date : ''}
${p.due_date ? ' | Due: ' + p.due_date : ''}
</small>
${p.men_on_site ? '<br><small>Men: ' + p.men_on_site + '</small>' : ''}
</div>
</div>
`).join('')}
</div>
`;
}).join('')}
</div>
`}
</div>
</div>
</div>
`;
}).join('');
if (!container.innerHTML.trim()) {
container.innerHTML = '<div class="col"><div class="alert alert-info">No matching phases found</div></div>';
}
}
document.addEventListener('DOMContentLoaded', () => {
loadSchedule();
document.getElementById('jobFilter').addEventListener('change', renderSchedule);
document.getElementById('phaseTypeFilter').addEventListener('change', renderSchedule);
document.getElementById('statusFilter').addEventListener('change', renderSchedule);
});
</script>
{% endblock %}

265
import_data.py Normal file
View File

@@ -0,0 +1,265 @@
#!/usr/bin/env python3
"""
Import data from Excel spreadsheets into the Fire Alarm Management database.
"""
import pandas as pd
from datetime import datetime
import sys
import os
# Add the app directory to the path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from app import create_app
from app.models import db, Job, Phase, Material
def parse_date(value):
"""Parse date from various formats."""
if pd.isna(value) or value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
try:
return datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
try:
return datetime.strptime(value, '%m/%d/%Y').date()
except ValueError:
return None
return None
def parse_float(value, default=0.0):
"""Parse float from various formats."""
if pd.isna(value) or value is None:
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def parse_int(value, default=None):
"""Parse integer from various formats."""
if pd.isna(value) or value is None:
return default
try:
return int(float(value))
except (ValueError, TypeError):
return default
def parse_str(value):
"""Parse string, returning None for NaN values."""
if pd.isna(value) or value is None:
return None
return str(value).strip() if str(value).strip() else None
def import_fire_alarm_jobs(app, filepath):
"""Import jobs from the Fire Alarm Information spreadsheet."""
print(f"\nImporting jobs from: {filepath}")
try:
df = pd.read_excel(filepath, sheet_name='Sheet1')
print(f"Found {len(df)} rows")
except Exception as e:
print(f"Error reading file: {e}")
return
with app.app_context():
imported = 0
for idx, row in df.iterrows():
job_number = parse_str(row.get('JOB NUMBER'))
if not job_number:
print(f" Skipping row {idx}: No job number")
continue
# Check if job already exists
existing = Job.query.filter_by(job_number=job_number).first()
if existing:
print(f" Skipping {job_number}: Already exists")
continue
job = Job(
job_number=job_number,
job_name=parse_str(row.get('JOB NAME')) or f"Job {job_number}",
location=parse_str(row.get('LOCATION')),
percent_complete=parse_float(row.get('% COMPLETE'), 0.0),
est_starting_qtr=parse_str(row.get('Est. Starting Qtr')),
fire_alarm_budget=parse_float(row.get('FIRE ALARM BUDGET - VENDOR BID APPROVAL')),
labor_estimate=parse_float(row.get('LABOR ESTIMATE')),
material_estimate=parse_float(row.get('MATERIAL ESTIMATE')),
amount_left_on_contract=parse_float(row.get('AMOUNT LEFT ON CONTRACT')),
pm_assigned=parse_str(row.get('PM ASSIGNED')),
aor=parse_str(row.get('AOR')),
fire_vendor=parse_str(row.get('FIRE VENDOR')),
install_partner=parse_str(row.get('INSTALL PARTNER TEAM UP WITH RER')),
ps_or_install=parse_str(row.get('P/S OR INSTALL')),
voip_or_phone=parse_str(row.get('VOIP OR PHONE LINE')),
plans=parse_str(row.get('Plans')),
notes=parse_str(row.get('NOTES')),
issues=parse_str(row.get('ISSUES')),
milestone_1=parse_str(row.get('1ST MILESTONE')),
milestone_2=parse_str(row.get('2ND MILESTONE')),
milestone_3=parse_str(row.get('3RD MILESTONE')),
milestone_4=parse_str(row.get('4TH MILESTONE')),
milestone_5=parse_str(row.get('5TH MILESTONE')),
milestone_6=parse_str(row.get('6TH MILESTONE')),
milestone_7=parse_str(row.get('7TH MILESTONE')),
elevator_final=parse_date(row.get('ELEVATOR FINAL')),
pretest=parse_date(row.get('PRETEST')),
final_date=parse_date(row.get('FINAL')),
co_drop_dead_date=parse_date(row.get('C/O DROP DEAD DATE')),
number_of_units=parse_int(row.get('NUMBER OF UNITS')),
sep_club_house=parse_str(row.get('SEP CLUB HOUSE - FIRE ALARM')),
)
db.session.add(job)
imported += 1
print(f" Imported: {job_number} - {job.job_name}")
db.session.commit()
print(f"\nImported {imported} jobs")
def import_schedule_data(app, filepath):
"""Import schedule/phase data from the schedule spreadsheet."""
print(f"\nImporting schedule data from: {filepath}")
try:
xl = pd.ExcelFile(filepath)
print(f"Found sheets: {xl.sheet_names}")
except Exception as e:
print(f"Error reading file: {e}")
return
with app.app_context():
# Read Schedule sheet
try:
schedule_df = pd.read_excel(xl, sheet_name='Sechdule')
print(f"\nProcessing Schedule sheet with {len(schedule_df)} rows")
# The first row contains job info, remaining rows contain phase details
if len(schedule_df) > 0:
# Get job info from first row
first_row = schedule_df.iloc[0]
job_number = parse_str(first_row.get('Job #'))
if job_number:
job = Job.query.filter_by(job_number=job_number).first()
if not job:
# Create the job if it doesn't exist
job = Job(
job_number=job_number,
job_name=parse_str(first_row.get('Job Name')) or f"Job {job_number}",
pm_assigned=parse_str(first_row.get('PM RAR')),
subcontractor=parse_str(first_row.get('Subcontractor')),
pci=parse_str(first_row.get('PCI')),
)
db.session.add(job)
db.session.commit()
print(f" Created job: {job_number}")
else:
# Update job info
if not job.subcontractor:
job.subcontractor = parse_str(first_row.get('Subcontractor'))
if not job.pci:
job.pci = parse_str(first_row.get('PCI'))
db.session.commit()
print(f" Updated job: {job_number}")
# Import phases from the schedule
phase_types = ['Rough-in', 'Trim', 'Commissioning', 'Final', 'Turnover']
phase_type_map = {
'Rough-in': 'rough_in',
'Trim': 'trim',
'Commissioning': 'commissioning',
'Final': 'final',
'Turnover': 'turnover'
}
phases_imported = 0
for phase_type in phase_types:
for phase_num in range(1, 51):
col_name = f"{phase_type} Phase {phase_num}"
if col_name in schedule_df.columns:
# Check if we have points data for this phase (row index 1)
if len(schedule_df) > 1:
points_row = schedule_df.iloc[1]
points = parse_int(points_row.get(col_name))
if points and points > 0:
# Check if phase already exists
existing = Phase.query.filter_by(
job_id=job.id,
phase_type=phase_type_map[phase_type],
phase_number=phase_num
).first()
if not existing:
phase = Phase(
job_id=job.id,
phase_type=phase_type_map[phase_type],
phase_number=phase_num,
points=points,
)
# Try to get start/due dates from other rows
if len(schedule_df) > 3:
start_row = schedule_df.iloc[3]
phase.start_date = parse_date(start_row.get(col_name))
if len(schedule_df) > 4:
due_row = schedule_df.iloc[4]
phase.due_date = parse_date(due_row.get(col_name))
if len(schedule_df) > 5:
men_row = schedule_df.iloc[5]
phase.men_on_site = parse_int(men_row.get(col_name))
if len(schedule_df) > 6:
completed_row = schedule_df.iloc[6]
completed_val = completed_row.get(col_name)
phase.completed = completed_val == True or str(completed_val).lower() == 'true'
db.session.add(phase)
phases_imported += 1
db.session.commit()
print(f" Imported {phases_imported} phases for job {job_number}")
except Exception as e:
print(f"Error processing Schedule sheet: {e}")
def main():
print("="*50)
print("Fire Alarm Data Import")
print("="*50)
app = create_app()
# File paths
fire_alarm_file = '/root/code/romanoff/Raleigh jobs FIRE ALARM INFORMATION.xlsx'
schedule_file = '/root/code/romanoff/schedule_updated.xlsm'
# Import jobs from fire alarm spreadsheet
if os.path.exists(fire_alarm_file):
import_fire_alarm_jobs(app, fire_alarm_file)
else:
print(f"File not found: {fire_alarm_file}")
# Import schedule data
if os.path.exists(schedule_file):
import_schedule_data(app, schedule_file)
else:
print(f"File not found: {schedule_file}")
print("\n" + "="*50)
print("Import complete!")
print("="*50)
if __name__ == '__main__':
main()

BIN
instance/fire_alarm.db Normal file

Binary file not shown.

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
flask>=3.0.0
flask-sqlalchemy>=3.1.0
pandas>=2.0.0
openpyxl>=3.1.0

20
run.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python3
"""
Fire Alarm Management Application
Run this file to start the web server.
"""
from app import create_app
app = create_app()
if __name__ == '__main__':
print("\n" + "="*50)
print("Fire Alarm Management System")
print("="*50)
print("\nStarting server...")
print("Open http://localhost:5000 in your browser")
print("\nPress Ctrl+C to stop the server")
print("="*50 + "\n")
app.run(debug=True, host='0.0.0.0', port=5000)

BIN
schedule_updated.xlsm Normal file

Binary file not shown.