diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..5907d17 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,96 @@ +name: Build and Publish TimeTracker Docker Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + release: + types: [ published ] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: drytrix/timetracker + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + include: + - name: amd64 + platform: linux/amd64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Check files and create combined Dockerfile + run: | + echo "--- Checking available files ---" + pwd + ls -la + echo "--- Checking if requirements.txt exists ---" + if [ -f requirements.txt ]; then + echo "requirements.txt found:" + cat requirements.txt + else + echo "requirements.txt NOT found!" + echo "Available .txt files:" + find . -name "*.txt" -type f + fi + + echo "--- Creating combined Dockerfile ---" + cp Dockerfile Dockerfile.final + echo "Combined Dockerfile created successfully" + + - name: Build and push Docker image + run: | + IMAGE_ID=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + # Extract version from ref + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + elif [[ "${GITHUB_REF}" == refs/heads/* ]]; then + VERSION=${GITHUB_REF#refs/heads/} + else + VERSION=unknown + fi + + # Replace any slashes with dashes (for feature branches etc.) + VERSION=${VERSION//\//-} + + echo "Image ID: $IMAGE_ID" + echo "Version: $VERSION" + + # Build the Docker image + docker build -f Dockerfile.final -t $IMAGE_ID:$VERSION . + + # Always push versioned tag on releases/tags + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo ${{ secrets.GITHUB_TOKEN }} | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin + docker push $IMAGE_ID:$VERSION + + # If this is a release, also push as latest + if [ "${{ github.event_name }}" == "release" ]; then + docker tag $IMAGE_ID:$VERSION $IMAGE_ID:latest + docker push $IMAGE_ID:latest + fi + fi diff --git a/ANALYTICS_FEATURE.md b/ANALYTICS_FEATURE.md deleted file mode 100644 index c9bb053..0000000 --- a/ANALYTICS_FEATURE.md +++ /dev/null @@ -1,165 +0,0 @@ -# Visual Analytics Feature - -## Overview - -The Visual Analytics feature provides interactive charts and graphs for better data visualization in the TimeTracker application. This feature enhances the existing reporting system by offering visual insights into time tracking data. - -## Features - -### ๐Ÿ“Š Chart Types - -1. **Daily Hours Trend** - Line chart showing hours worked per day over a selected time period -2. **Billable vs Non-Billable** - Doughnut chart showing the breakdown of billable and non-billable hours -3. **Hours by Project** - Bar chart displaying total hours spent on each project -4. **Weekly Trends** - Line chart showing weekly hour patterns -5. **Hours by Time of Day** - Line chart showing when during the day work is typically performed -6. **Project Efficiency** - Dual-axis bar chart comparing hours vs revenue for projects -7. **User Performance** - Bar chart showing hours worked per user (admin only) - -### ๐ŸŽ›๏ธ Interactive Controls - -- **Time Range Selector**: Choose between 7, 30, 90, or 365 days -- **Refresh Button**: Manually refresh all charts -- **Responsive Design**: Charts automatically resize for different screen sizes -- **Mobile Optimization**: Dedicated mobile template for better mobile experience - -### ๐Ÿ“ฑ Mobile Experience - -- Optimized for touch devices -- Simplified layout for small screens -- Responsive chart sizing -- Touch-friendly controls - -## Technical Implementation - -### Backend - -- **New Route**: `/analytics` - Main analytics dashboard -- **API Endpoints**: RESTful API endpoints for chart data -- **Data Aggregation**: SQL queries with proper user permission filtering -- **Performance**: Efficient database queries with proper indexing - -### Frontend - -- **Chart.js 4.4.0**: Modern, lightweight charting library -- **Responsive Design**: Bootstrap 5 grid system -- **JavaScript Classes**: Modular, maintainable code structure -- **Error Handling**: Graceful error handling with user notifications - -### API Endpoints - -``` -GET /api/analytics/hours-by-day?days={days} -GET /api/analytics/hours-by-project?days={days} -GET /api/analytics/hours-by-user?days={days} -GET /api/analytics/hours-by-hour?days={days} -GET /api/analytics/billable-vs-nonbillable?days={days} -GET /api/analytics/weekly-trends?weeks={weeks} -GET /api/analytics/project-efficiency?days={days} -``` - -## Installation - -1. **Dependencies**: Chart.js is loaded from CDN (no additional npm packages required) -2. **Database**: No new database migrations required -3. **Configuration**: No additional configuration needed - -## Usage - -### Accessing Analytics - -1. Navigate to the main navigation menu -2. Click on "Analytics" (new menu item) -3. View the interactive dashboard with various charts - -### Using the Dashboard - -1. **Select Time Range**: Use the dropdown to choose your preferred time period -2. **View Charts**: Each chart provides different insights into your data -3. **Refresh Data**: Click the refresh button to update all charts -4. **Mobile View**: Automatically detects mobile devices and serves optimized template - -### Chart Interactions - -- **Hover**: Hover over chart elements to see detailed information -- **Zoom**: Some charts support zooming and panning -- **Responsive**: Charts automatically resize when the browser window changes - -## Security - -- **Authentication Required**: All analytics endpoints require user login -- **Permission Filtering**: Data is filtered based on user permissions -- **Admin Features**: User performance charts are only visible to admin users -- **Data Isolation**: Users can only see their own data (unless admin) - -## Performance Considerations - -- **Efficient Queries**: Database queries are optimized with proper joins and filtering -- **Caching**: Chart data is fetched on-demand, not pre-cached -- **Lazy Loading**: Charts are loaded asynchronously for better performance -- **Mobile Optimization**: Reduced chart complexity on mobile devices - -## Customization - -### Adding New Charts - -1. Create new API endpoint in `app/routes/analytics.py` -2. Add chart HTML to the dashboard template -3. Implement chart loading logic in the JavaScript controller -4. Add any necessary CSS styling - -### Modifying Chart Styles - -- Chart colors and styles are defined in the JavaScript code -- Global Chart.js defaults are configured in the dashboard -- CSS classes can be added for additional styling - -### Time Periods - -- Default time periods can be modified in the HTML templates -- API endpoints accept custom day/week parameters -- New time period options can be easily added - -## Browser Support - -- **Modern Browsers**: Chrome, Firefox, Safari, Edge (latest versions) -- **Mobile Browsers**: iOS Safari, Chrome Mobile, Samsung Internet -- **Chart.js**: Supports IE11+ (with polyfills) - -## Troubleshooting - -### Common Issues - -1. **Charts Not Loading**: Check browser console for JavaScript errors -2. **Data Not Displaying**: Verify user permissions and database connectivity -3. **Mobile Display Issues**: Ensure mobile template is being served correctly -4. **Performance Issues**: Check database query performance and network latency - -### Debug Mode - -- Enable browser developer tools to see console logs -- Check network tab for API request/response details -- Verify Chart.js library is loading correctly - -## Future Enhancements - -- **Export Charts**: Save charts as images or PDFs -- **Custom Dashboards**: User-configurable chart layouts -- **Real-time Updates**: Live chart updates via WebSocket -- **Advanced Filtering**: More granular data filtering options -- **Chart Annotations**: Add notes and highlights to charts -- **Data Drill-down**: Click to explore detailed data views - -## Contributing - -When adding new charts or modifying existing ones: - -1. Follow the existing code structure and patterns -2. Ensure mobile responsiveness -3. Add proper error handling -4. Update documentation -5. Test on both desktop and mobile devices - -## License - -This feature is part of the TimeTracker application and follows the same licensing terms. diff --git a/Dockerfile b/Dockerfile index 529536a..c2f5ccf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.11-slim-bullseye # Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 @@ -10,6 +10,16 @@ ENV FLASK_ENV=production RUN apt-get update && apt-get install -y \ curl \ tzdata \ + # WeasyPrint dependencies (Debian Bullseye package names) + libgdk-pixbuf2.0-0 \ + libpango-1.0-0 \ + libcairo2 \ + libpangocairo-1.0-0 \ + libffi-dev \ + shared-mime-info \ + # Additional fonts and rendering support + fonts-liberation \ + fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* # Set work directory @@ -28,16 +38,20 @@ COPY . . # Create data and logs directories with proper permissions RUN mkdir -p /data /app/logs && chmod 755 /data && chmod 755 /app/logs -# Copy the fixed startup script -COPY docker/start-fixed.sh /app/start.sh +# Create upload directories with proper permissions +RUN mkdir -p /app/app/static/uploads/logos /app/static/uploads/logos && \ + chmod -R 755 /app/app/static/uploads && \ + chmod -R 755 /app/static/uploads + +# Copy the startup script and ensure it's executable +COPY docker/start-new.sh /app/start.sh # Make startup scripts executable -RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py /app/docker/start-fixed.sh +RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py /app/docker/test-routing.py # Create non-root user RUN useradd -m -u 1000 timetracker && \ - chown -R timetracker:timetracker /app /data /app/logs && \ - chmod +x /app/start.sh + chown -R timetracker:timetracker /app /data /app/logs /app/app/static/uploads /app/static/uploads # Verify startup script exists and is accessible RUN ls -la /app/start.sh && \ diff --git a/Dockerfile.fixed b/Dockerfile.fixed new file mode 100644 index 0000000..c14c618 --- /dev/null +++ b/Dockerfile.fixed @@ -0,0 +1,60 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app +ENV FLASK_ENV=production + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Set default timezone +ENV TZ=Europe/Rome + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Create data and logs directories with proper permissions +RUN mkdir -p /data /app/logs && chmod 755 /data && chmod 755 /app/logs + +# Copy and fix the startup script +COPY docker/start-simple.sh /app/start.sh + +# Ensure proper line endings and make executable +RUN sed -i 's/\r$//' /app/start.sh && \ + chmod +x /app/start.sh + +# Make other scripts executable +RUN chmod +x /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py + +# Create non-root user +RUN useradd -m -u 1000 timetracker && \ + chown -R timetracker:timetracker /app /data /app/logs + +# Verify startup script exists and is accessible +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh && \ + cat /app/start.sh + +USER timetracker + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/_health || exit 1 + +# Run the application +CMD ["/app/start.sh"] diff --git a/Dockerfile.minimal b/Dockerfile.minimal new file mode 100644 index 0000000..982bff8 --- /dev/null +++ b/Dockerfile.minimal @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Copy the minimal startup script +COPY docker/start-minimal.sh /app/start.sh + +# Make executable +RUN chmod +x /app/start.sh + +# Verify startup script exists +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh + +# Expose port +EXPOSE 8080 + +# Run the application +CMD ["/app/start.sh"] diff --git a/Dockerfile.permissions-fixed b/Dockerfile.permissions-fixed new file mode 100644 index 0000000..cbf11cc --- /dev/null +++ b/Dockerfile.permissions-fixed @@ -0,0 +1,79 @@ +FROM python:3.11-slim-bullseye + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app +ENV FLASK_ENV=production + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + tzdata \ + # WeasyPrint dependencies (Debian Bullseye package names) + libgdk-pixbuf2.0-0 \ + libpango-1.0-0 \ + libcairo2 \ + libpangocairo-1.0-0 \ + libffi-dev \ + shared-mime-info \ + # Additional fonts and rendering support + fonts-liberation \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Set default timezone +ENV TZ=Europe/Rome + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Create all necessary directories with proper permissions BEFORE creating user +RUN mkdir -p /data /app/logs && \ + mkdir -p /app/app/static/uploads/logos && \ + mkdir -p /app/static/uploads/logos && \ + mkdir -p /app/app/static/uploads/temp && \ + mkdir -p /app/static/uploads/temp + +# Set proper permissions on directories +RUN chmod -R 755 /data && \ + chmod -R 755 /app/logs && \ + chmod -R 755 /app/app/static/uploads && \ + chmod -R 755 /app/static/uploads + +# Copy the startup script and ensure it's executable +COPY docker/start-new.sh /app/start.sh + +# Make startup scripts executable +RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py /app/docker/test-routing.py + +# Create non-root user with specific UID/GID +RUN groupadd -g 1000 timetracker && \ + useradd -m -u 1000 -g 1000 timetracker + +# Change ownership of ALL application files and directories to timetracker user +RUN chown -R timetracker:timetracker /app /data /app/logs + +# Verify startup script exists and is accessible +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh + +# Switch to non-root user +USER timetracker + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/_health || exit 1 + +# Run the application +CMD ["/app/start.sh"] diff --git a/Dockerfile.python b/Dockerfile.python new file mode 100644 index 0000000..23ba27f --- /dev/null +++ b/Dockerfile.python @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Copy the Python startup script +COPY docker/start.py /app/start.py + +# Make executable +RUN chmod +x /app/start.py + +# Verify startup script exists +RUN ls -la /app/start.py && \ + head -1 /app/start.py + +# Expose port +EXPOSE 8080 + +# Run the application using Python +CMD ["python", "/app/start.py"] diff --git a/Dockerfile.weasyprint b/Dockerfile.weasyprint new file mode 100644 index 0000000..ca26970 --- /dev/null +++ b/Dockerfile.weasyprint @@ -0,0 +1,69 @@ +FROM python:3.11-slim-bullseye + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=app +ENV FLASK_ENV=production + +# Install system dependencies for WeasyPrint +RUN apt-get update && apt-get install -y \ + curl \ + tzdata \ + # Core WeasyPrint dependencies + libgdk-pixbuf2.0-0 \ + libpango-1.0-0 \ + libcairo2 \ + libpangocairo-1.0-0 \ + libffi-dev \ + shared-mime-info \ + # Font support + fonts-liberation \ + fonts-dejavu-core \ + # Additional system libraries + libglib2.0-0 \ + libgirepository1.0-1 \ + # Clean up + && rm -rf /var/lib/apt/lists/* + +# Set work directory +WORKDIR /app + +# Set default timezone +ENV TZ=Europe/Rome + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . . + +# Create data and logs directories with proper permissions +RUN mkdir -p /data /app/logs && chmod 755 /data && chmod 755 /app/logs + +# Copy the startup script and ensure it's executable +COPY docker/start-new.sh /app/start.sh + +# Make startup scripts executable +RUN chmod +x /app/start.sh /app/docker/init-database.py /app/docker/init-database-sql.py /app/docker/test-db.py /app/docker/test-routing.py + +# Create non-root user +RUN useradd -m -u 1000 timetracker && \ + chown -R timetracker:timetracker /app /data /app/logs + +# Verify startup script exists and is accessible +RUN ls -la /app/start.sh && \ + head -1 /app/start.sh + +USER timetracker + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/_health || exit 1 + +# Run the application +CMD ["/app/start.sh"] diff --git a/README.md b/README.md index a52a3b9..de0167f 100644 --- a/README.md +++ b/README.md @@ -1,601 +1,131 @@ -# TimeTracker โฑ๏ธ +# TimeTracker - Professional Time Tracking Application -[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) -[![Python](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) -[![Flask](https://img.shields.io/badge/Flask-2.3+-green.svg)](https://flask.palletsprojects.com/) -[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) -[![Platform](https://img.shields.io/badge/Platform-Raspberry%20Pi-red.svg)](https://www.raspberrypi.org/) - -A robust, self-hosted time tracking application designed for teams and freelancers who need reliable time management without cloud dependencies. Built with Flask and optimized for Raspberry Pi deployment, TimeTracker provides persistent timers, comprehensive reporting, and a modern web interface with enhanced task management capabilities. - -## ๐ŸŽฏ What Problem Does It Solve? - -**TimeTracker addresses the common pain points of time tracking:** - -- **Lost Time Data**: Traditional timers lose data when browsers close or computers restart -- **Cloud Dependency**: No need for external services or internet connectivity -- **Complex Setup**: Simple Docker deployment on Raspberry Pi or any Linux system -- **Limited Reporting**: Built-in comprehensive reports and CSV exports -- **Team Management**: User roles, project organization, and billing support -- **Task Organization**: Break down projects into manageable tasks with modern UI/UX - -**Perfect for:** -- Freelancers tracking billable hours -- Small teams managing project time -- Consultants needing client billing reports -- Project managers organizing work into tasks -- Anyone wanting self-hosted time tracking with task management - -## โœจ Features - -### ๐Ÿ• Time Tracking -- **Persistent Timers**: Server-side timers that survive browser restarts -- **Manual Entry**: Log time with start/end dates and project selection -- **Idle Detection**: Automatic timeout for inactive sessions -- **Multiple Projects**: Track time across different clients and projects -- **Timezone Support**: Full timezone awareness with configurable local time display - -### ๐Ÿ‘ฅ User Management -- **Role-Based Access**: Admin and regular user roles -- **Simple Authentication**: Username-based login (no passwords required) -- **User Profiles**: Personal settings and time preferences -- **Self-Registration**: Optional user account creation - -### ๐Ÿ“Š Reporting & Analytics -- **Project Reports**: Time breakdown by project and client -- **User Reports**: Individual time tracking and productivity -- **CSV Export**: Data backup and external analysis -- **Real-time Updates**: Live timer status and progress - -### ๐Ÿ—๏ธ Project Management -- **Client Projects**: Organize work by client and project -- **Billing Support**: Hourly rates and billable time tracking -- **Project Status**: Active, completed, and archived projects -- **Time Rounding**: Configurable time rounding for billing - -### โœ… Enhanced Task Management -- **Modern UI/UX Design**: Beautiful, responsive card-based layout with hover effects -- **Project Breakdown**: Break projects into manageable tasks with visual hierarchy -- **Status Tracking**: Monitor task progress (To Do, In Progress, Review, Done, Cancelled) -- **Priority Management**: Set and track task priorities (Low, Medium, High, Urgent) with color-coded badges -- **Time Estimation**: Estimate and track actual time for tasks with progress bars -- **Task Assignment**: Assign tasks to team members with user avatars -- **Due Date Tracking**: Set deadlines with overdue notifications and visual indicators -- **Quick Actions**: One-click status updates and task management -- **Advanced Filtering**: Search, filter by status/priority/project/assignee -- **Task Timeline**: Visual timeline showing task creation, assignment, and completion events -- **Mobile-First Design**: Responsive design that works perfectly on all devices -- **Interactive Elements**: Hover effects, smooth transitions, and modern animations -- **Quick Stats**: Overview cards showing task counts by status -- **Automatic Migration**: Database tables are automatically created on first startup - -### ๐Ÿš€ Technical Features -- **Responsive Design**: Works on desktop, tablet, and mobile with modern UI components -- **HTMX Integration**: Dynamic interactions without JavaScript complexity -- **PostgreSQL Database**: Robust database with automatic initialization -- **Docker Ready**: Easy deployment and scaling -- **RESTful API**: Programmatic access to time data -- **Timezone Management**: Comprehensive timezone support with 100+ options -- **Modern CSS**: Bootstrap 5 with custom styling and CSS variables - -## ๐Ÿ–ผ๏ธ Screenshots - -### Dashboard View -![Dashboard](assets/screenshots/Dashboard.png) -- Clean, intuitive interface showing active timers and recent activity -- Quick access to start/stop timers and manual time entry -- Real-time timer status and project selection - -### Enhanced Task Management -![Task Management](assets/screenshots/Task_Management.png) -- Modern card-based layout with priority-based color coding -- Quick stats overview showing task distribution by status -- Advanced filtering and search capabilities -- Responsive design optimized for all devices -- Interactive elements with hover effects and smooth transitions - -### Project Management -![Projects](assets/screenshots/Projects.png) -- Client and project organization with billing information -- Time tracking across multiple projects simultaneously -- Project status management and billing configuration - -### Reports & Analytics -![Reports](assets/screenshots/Reports.png) -- Comprehensive time reports with export capabilities -- Visual breakdowns of time allocation and productivity -- Detailed time tracking data and export options - -## ๐Ÿณ Docker Images - -### GitHub Container Registry (GHCR) - -TimeTracker provides pre-built Docker images available on **GitHub Container Registry (GHCR)**: - -[![Docker Image](https://img.shields.io/badge/Docker%20Image-ghcr.io/drytrix/timetracker-blue.svg)](https://github.com/DRYTRIX/TimeTracker/pkgs/container/timetracker) - -**Pull the latest image:** -```bash -docker pull ghcr.io/drytrix/timetracker:latest -``` - -**Available Tags:** -- `latest` - Latest stable build from main branch -- `main` - Latest build from main branch -- `v1.0.2` - Specific version releases -- `main-abc123` - Build from specific commit - -**Supported Architectures:** -- `linux/amd64` - Intel/AMD 64-bit - -### Container Types - -#### 1. Simple Container (Recommended for Production) - -The **simple container** is an all-in-one solution that includes both the TimeTracker application and PostgreSQL database in a single container. This is perfect for production deployments where you want simplicity and don't need separate database management. - -**Features:** -- โœ… **All-in-one**: Flask app + PostgreSQL in single container -- โœ… **Auto-initialization**: Database automatically created and configured -- โœ… **Automatic migration**: Task Management tables created automatically -- โœ… **Persistent storage**: Data survives container restarts -- โœ… **Production ready**: Optimized for deployment -- โœ… **Timezone support**: Full timezone management with 100+ options -- โœ… **Enhanced UI**: Modern task management interface with responsive design - -**Run with docker-compose:** -```bash -# Clone the repository -git clone https://github.com/DRYTRIX/TimeTracker.git -cd TimeTracker - -# Start the simple container -docker-compose -f docker-compose.simple.yml up -d -``` - -**Run directly:** -```bash -docker run -d \ - --name timetracker \ - -p 8080:8080 \ - -v timetracker_data:/var/lib/postgresql/data \ - -v timetracker_logs:/app/logs \ - -e FORCE_REINIT=false \ - ghcr.io/drytrix/timetracker:latest -``` - -**Environment Variables:** -- `FORCE_REINIT`: Set to `true` to reinitialize database schema (default: `false`) -- `TZ`: Timezone (default: `Europe/Rome`) - -**Note:** Task Management tables are automatically created on first startup if they don't exist. - -#### 2. Public Container (Development/Testing) - -The **public container** is designed for development and testing scenarios where you want to use external databases or have more control over the setup. - -**Features:** -- ๐Ÿ”ง **Development focused**: External database configuration -- ๐Ÿ”ง **Flexible setup**: Use your own PostgreSQL/MySQL -- ๐Ÿ”ง **Custom configuration**: Full control over database settings - -**Run with docker-compose:** -```bash -# Use the public docker-compose file -docker-compose -f docker-compose.public.yml up -d -``` - -**Run directly:** -```bash -docker run -d \ - --name timetracker \ - -p 8080:8080 \ - -e DATABASE_URL=postgresql://user:pass@host:5432/db \ - -e SECRET_KEY=your-secret-key \ - ghcr.io/drytrix/timetracker:latest -``` - -### Building Your Own Image - -For custom modifications or development: - -```bash -# Build locally (simple container with PostgreSQL) -docker build -f Dockerfile.simple -t timetracker:local . - -# Run with docker-compose -docker-compose -f docker-compose.simple.yml up -d -``` +A comprehensive web-based time tracking application built with Flask, featuring project management, time tracking, invoicing, and analytics. ## ๐Ÿš€ Quick Start ### Prerequisites +- Python 3.8+ +- Docker (optional) +- PostgreSQL (recommended) or SQLite -- **Docker** and **Docker Compose** installed -- **Network access** to the host system -- **Git** for cloning the repository +### Installation +1. Clone the repository +2. Install dependencies: `pip install -r requirements.txt` +3. Set up environment variables (see `env.example`) +4. Run the application: `python app.py` -### Installation Options +## ๐Ÿ“ Project Structure -#### Option 1: Simple Container (Recommended for Production) - -**All-in-one solution with built-in PostgreSQL database:** - -1. **Clone the repository:** - ```bash - git clone https://github.com/DRYTRIX/TimeTracker.git - cd TimeTracker - ``` - -2. **Start the application:** - ```bash - docker-compose -f docker-compose.simple.yml up -d - ``` - -3. **Access the application:** - ``` - http://localhost:8080 - ``` - -**Benefits:** -- โœ… **No external database required** - PostgreSQL included -- โœ… **Automatic initialization** - Database created automatically -- โœ… **Production ready** - Optimized for deployment -- โœ… **Persistent storage** - Data survives restarts -- โœ… **Simple setup** - One command deployment -- โœ… **Timezone support** - 100+ timezone options with automatic DST handling -- โœ… **Enhanced task management** - Modern UI with responsive design - -**Default credentials:** -- **Username**: `admin` -- **Password**: None required (username-based authentication) - -**Database Initialization:** -The container automatically: -1. Creates a PostgreSQL database named `timetracker` -2. Creates a user `timetracker` with full permissions -3. Initializes all tables with proper schema including task management -4. Inserts default admin user and project -5. Sets up triggers for automatic timestamp updates - -**Note:** Set `FORCE_REINIT=true` environment variable to reinitialize the database schema if you need to update the structure. - -#### Option 2: Public Container (Development/Testing) - -**For development or when you want external database control:** - -1. **Clone the repository:** - ```bash - git clone https://github.com/DRYTRIX/TimeTracker.git - cd TimeTracker - ``` - -2. **Configure environment variables:** - ```bash - cp env.example .env - # Edit .env with your database settings - ``` - -3. **Start the application:** - ```bash - docker-compose -f docker-compose.public.yml up -d - ``` - -4. **Access the application:** - ``` - http://localhost:8080 - ``` - -**Benefits:** -- ๐Ÿ”ง **Flexible database** - Use your own PostgreSQL/MySQL -- ๐Ÿ”ง **Development focused** - Full control over configuration -- ๐Ÿ”ง **Custom setup** - Configure as needed for your environment - -#### Option 3: Using Pre-built Image - -**Fastest deployment with GitHub Container Registry:** - -1. **Pull the image:** - ```bash - docker pull ghcr.io/drytrix/timetracker:latest - ``` - -2. **Run the container:** - ```bash - docker run -d \ - --name timetracker \ - -p 8080:8080 \ - -v timetracker_data:/var/lib/postgresql/data \ - -v timetracker_logs:/app/logs \ - ghcr.io/drytrix/timetracker:latest - ``` - -3. **Access the application:** - ``` - http://localhost:8080 - ``` - -### Configuration - -#### Simple Container Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `FORCE_REINIT` | Reinitialize database schema | `false` | -| `TZ` | Timezone | `Europe/Rome` | - -#### Public Container Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| `DATABASE_URL` | Database connection string | - | -| `SECRET_KEY` | Flask secret key | - | -| `TZ` | Timezone | `Europe/Rome` | -| `CURRENCY` | Currency for billing | `EUR` | -| `ROUNDING_MINUTES` | Time rounding in minutes | `1` | -| `SINGLE_ACTIVE_TIMER` | Allow only one active timer per user | `true` | -| `ALLOW_SELF_REGISTER` | Allow users to create accounts | `true` | -| `ADMIN_USERNAMES` | Comma-separated list of admin usernames | - | - -## ๐Ÿ“– Example Usage - -### Starting a Timer - -1. **Navigate to the dashboard** -2. **Select a project** from the dropdown -3. **Click "Start Timer"** to begin tracking -4. **Add notes** to describe what you're working on -5. **Timer runs continuously** even if you close the browser - -### Managing Tasks - -1. **Navigate to "Tasks"** in the main menu -2. **Create new tasks** with the enhanced form interface -3. **Set priorities and due dates** with visual indicators -4. **Assign tasks** to team members -5. **Track progress** with status updates and time logging -6. **Use advanced filtering** to find specific tasks -7. **View task details** with comprehensive information and timeline - -### Manual Time Entry - -1. **Go to "Manual Entry"** in the main menu -2. **Select project** and **date range** -3. **Enter start and end times** -4. **Add description** and **tags** -5. **Save** to log the time entry - -### Generating Reports - -1. **Access "Reports"** section -2. **Choose report type**: Project, User, or Summary -3. **Select date range** and **filters** -4. **View results** or **export to CSV** - -### Managing Projects - -1. **Admin users** can create new projects -2. **Set client information** and **billing rates** -3. **Assign users** to projects -4. **Track project status** and **completion** - -### Timezone Configuration - -1. **Access Admin Settings** (admin users only) -2. **Select your timezone** from 100+ available options -3. **View real-time preview** of current time in selected timezone -4. **Save settings** to apply timezone changes application-wide - -## ๐Ÿ—๏ธ Architecture - -### Technology Stack - -- **Backend**: Flask with SQLAlchemy ORM -- **Database**: PostgreSQL with automatic initialization -- **Frontend**: Server-rendered templates with modern CSS and responsive design -- **Real-time**: WebSocket for live timer updates -- **Containerization**: Docker with docker-compose -- **Timezone**: Full timezone support with pytz -- **UI Framework**: Bootstrap 5 with custom CSS variables and animations - -### Project Structure +The project has been organized for better maintainability: ``` TimeTracker/ -โ”œโ”€โ”€ app/ # Flask application +โ”œโ”€โ”€ app/ # Main Flask application โ”‚ โ”œโ”€โ”€ models/ # Database models โ”‚ โ”œโ”€โ”€ routes/ # Route handlers -โ”‚ โ”œโ”€โ”€ templates/ # Jinja2 templates with enhanced task management -โ”‚ โ”œโ”€โ”€ utils/ # Utility functions -โ”‚ โ””โ”€โ”€ config.py # Configuration settings -โ”œโ”€โ”€ docker/ # Docker configuration -โ”œโ”€โ”€ tests/ # Test suite -โ”œโ”€โ”€ docker-compose.simple.yml # Simple container setup -โ”œโ”€โ”€ docker-compose.public.yml # Public container setup -โ”œโ”€โ”€ Dockerfile.simple # Simple container Dockerfile -โ”œโ”€โ”€ requirements.txt # Python dependencies -โ””โ”€โ”€ README.md # This file +โ”‚ โ”œโ”€โ”€ static/ # Static assets (CSS, JS, images) +โ”‚ โ”œโ”€โ”€ templates/ # HTML templates +โ”‚ โ””โ”€โ”€ utils/ # Utility functions +โ”œโ”€โ”€ docs/ # Documentation and README files +โ”œโ”€โ”€ docker-configs/ # Docker configurations and Dockerfiles +โ”œโ”€โ”€ docker/ # Docker-related scripts and utilities +โ”‚ โ”œโ”€โ”€ config/ # Configuration files (Caddyfile, supervisord) +โ”‚ โ”œโ”€โ”€ fixes/ # Database and permission fix scripts +โ”‚ โ”œโ”€โ”€ migrations/ # Database migration scripts +โ”‚ โ”œโ”€โ”€ startup/ # Startup and initialization scripts +โ”‚ โ””โ”€โ”€ tests/ # Docker environment test scripts +โ”œโ”€โ”€ scripts/ # Deployment and utility scripts +โ”œโ”€โ”€ tests/ # Application test suite +โ”œโ”€โ”€ templates/ # Additional templates +โ”œโ”€โ”€ assets/ # Project assets and screenshots +โ””โ”€โ”€ logs/ # Application logs ``` -### Data Model +## ๐Ÿณ Docker Support -#### Core Entities +Multiple Docker configurations are available in `docker-configs/`: -- **Users**: Username-based authentication with role-based access -- **Projects**: Client projects with billing information and client management -- **Time Entries**: Manual and automatic time tracking with notes, tags, and billing support -- **Tasks**: Project breakdown with status, priority, assignment, and time tracking -- **Settings**: System configuration including timezone preferences +- **Standard**: `docker-compose.yml` - Full application with all features +- **Simple**: `docker-compose.simple.yml` - Minimal setup +- **Python**: `docker-compose.python.yml` - Python-only environment +- **WeasyPrint**: `docker-compose.weasyprint.yml` - With PDF generation +- **Fixed**: `docker-compose.fixed.yml` - Resolved permission issues -#### Database Schema +## ๐Ÿ”ง Features -The simple container automatically creates and initializes a PostgreSQL database with the following structure: +- **Time Tracking**: Start/stop timer with project and task association +- **Project Management**: Create and manage projects with client information +- **Task Management**: Organize work into tasks and categories +- **Invoicing**: Generate professional invoices from time entries +- **Analytics**: Comprehensive reporting and time analysis +- **User Management**: Multi-user support with role-based access +- **Mobile Responsive**: Works on all devices -**Users Table:** -- `id`, `username`, `role`, `created_at`, `last_login`, `is_active`, `updated_at` +## ๐Ÿ“š Documentation -**Projects Table:** -- `id`, `name`, `client`, `description`, `billable`, `hourly_rate`, `billing_ref`, `status`, `created_at`, `updated_at` +Detailed documentation is available in the `docs/` directory: -**Time Entries Table:** -- `id`, `user_id`, `project_id`, `task_id`, `start_utc`, `end_utc`, `duration_seconds`, `notes`, `tags`, `source`, `billable`, `created_at`, `updated_at` +- **API Documentation**: API endpoints and usage +- **Feature Guides**: Detailed feature explanations +- **Troubleshooting**: Common issues and solutions +- **Deployment**: Setup and deployment instructions -**Tasks Table:** -- `id`, `project_id`, `name`, `description`, `status`, `priority`, `assigned_to`, `created_by`, `due_date`, `estimated_hours`, `actual_hours`, `started_at`, `completed_at`, `created_at`, `updated_at` - -**Settings Table:** -- `id`, `timezone`, `currency`, `rounding_minutes`, `single_active_timer`, `allow_self_register`, `idle_timeout_minutes`, `backup_retention_days`, `backup_time`, `export_delimiter`, `created_at`, `updated_at` - -#### Key Features - -- **Timer Persistence**: Active timers survive server restarts -- **Billing Support**: Hourly rates, billable flags, and cost calculations -- **Export Capabilities**: CSV export for reports and data backup -- **Responsive Design**: Works on desktop and mobile devices with modern UI -- **Timezone Support**: Full timezone awareness with automatic DST handling -- **Task Management**: Comprehensive task organization with modern interface - -## ๐Ÿ› ๏ธ Development - -### Local Development Setup - -1. **Install Python 3.11+:** - ```bash - python --version # Should be 3.11 or higher - ``` - -2. **Install dependencies:** - ```bash - pip install -r requirements.txt - ``` - -3. **Set up environment:** - ```bash - cp env.example .env - # Edit .env with development settings - ``` - -4. **Initialize database:** - ```bash - flask db upgrade - ``` - -5. **Run development server:** - ```bash - flask run - ``` - -### Testing +## ๐Ÿš€ Deployment +### Docker Deployment ```bash -# Run all tests -python -m pytest - -# Run with coverage -python -m pytest --cov=app - -# Run specific test file -python -m pytest tests/test_timer.py +# Use the appropriate docker-compose file +docker-compose -f docker-configs/docker-compose.yml up -d ``` -### Code Quality +### Manual Deployment +```bash +# Install dependencies +pip install -r requirements.txt -- **Style**: PEP 8 compliance with Black formatter -- **Type Hints**: Python type annotations where appropriate -- **Documentation**: Docstrings for all public functions -- **Testing**: Comprehensive test coverage -- **UI/UX**: Modern design principles with accessibility considerations +# Set environment variables +cp env.example .env +# Edit .env with your configuration -## ๐Ÿ”’ Security Considerations +# Run the application +python app.py +``` -- **LAN-only deployment**: Designed for internal network use -- **Username-only auth**: Simple authentication suitable for trusted environments -- **CSRF protection**: Disabled for simplified development and API usage -- **Session management**: Secure cookie-based sessions +## ๐Ÿงช Testing -## ๐Ÿ’พ Backup and Maintenance - -- **Automatic backups**: Nightly PostgreSQL database backups -- **Manual exports**: On-demand CSV exports and full data dumps -- **Health monitoring**: Built-in health check endpoints -- **Database migrations**: Version-controlled schema changes +Run the test suite: +```bash +python -m pytest tests/ +``` ## ๐Ÿค Contributing -We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on: +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request -- How to submit bug reports and feature requests -- Development setup and coding standards -- Pull request process and guidelines -- Code of conduct and community guidelines - -### Quick Contribution Steps - -1. **Fork** the repository -2. **Create** a feature branch -3. **Make** your changes -4. **Test** thoroughly -5. **Submit** a pull request +See `docs/CONTRIBUTING.md` for detailed guidelines. ## ๐Ÿ“„ License -This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details. - -The GPL v3 license ensures that: -- โœ… **Derivatives remain open source** -- โœ… **Source code is always available** -- โœ… **Users have freedom to modify and distribute** -- โœ… **Commercial use is permitted** +This project is licensed under the MIT License - see the `docs/LICENSE` file for details. ## ๐Ÿ†˜ Support -### Getting Help +- **Issues**: Report bugs and feature requests on GitHub +- **Documentation**: Check the `docs/` directory +- **Troubleshooting**: See `docs/SOLUTION_GUIDE.md` -- **Documentation**: Check this README and code comments -- **Issues**: Report bugs and request features on GitHub -- **Discussions**: Ask questions and share ideas -- **Wiki**: Community-maintained documentation (coming soon) +## ๐Ÿ”„ Recent Updates -### Common Issues - -- **Timer not starting**: Check if another timer is already active -- **Database errors**: Ensure proper permissions and disk space -- **Docker issues**: Verify Docker and Docker Compose installation -- **Network access**: Check firewall settings and port configuration -- **Timezone issues**: Verify timezone settings in admin panel -- **Task management**: Ensure database schema is properly initialized - -## ๐Ÿš€ Roadmap - -### Planned Features - -- [ ] **Mobile App**: Native iOS and Android applications -- [ ] **API Enhancements**: RESTful API for third-party integrations -- [ ] **Advanced Reporting**: Charts, graphs, and analytics dashboard -- [ ] **Team Collaboration**: Shared projects and time approval workflows -- [ ] **Integration**: Zapier, Slack, and other platform connections -- [ ] **Multi-language**: Internationalization support -- [ ] **Task Templates**: Predefined task structures for common workflows -- [ ] **Advanced Notifications**: Email and push notifications for task updates - -### Recent Updates - -- **v1.0.0**: Initial release with core time tracking features -- **v1.1.0**: Added comprehensive reporting and export capabilities -- **v1.2.0**: Enhanced project management and billing support -- **v1.3.0**: Added comprehensive timezone support with 100+ options -- **v1.4.0**: Enhanced task management with modern UI/UX design and responsive layout - -## ๐Ÿ™ Acknowledgments - -- **Flask Community**: For the excellent web framework -- **SQLAlchemy Team**: For robust database ORM -- **Docker Community**: For containerization tools -- **Bootstrap Team**: For the responsive CSS framework -- **Contributors**: Everyone who has helped improve TimeTracker +- **Project Cleanup**: Reorganized project structure for better maintainability +- **Docker Organization**: Consolidated Docker configurations and scripts +- **Documentation**: Moved all documentation to dedicated `docs/` directory +- **Script Organization**: Grouped utility scripts by purpose --- -**Made with โค๏ธ for the open source community** - -*TimeTracker - Track your time, not your patience* +**Note**: This project has been cleaned up and reorganized. All files have been preserved and moved to appropriate directories for better organization and maintainability. diff --git a/app.py b/app.py index f9af604..785de2d 100644 --- a/app.py +++ b/app.py @@ -5,7 +5,7 @@ Time Tracker Application Entry Point import os from app import create_app, db -from app.models import User, Project, TimeEntry, Task, Settings +from app.models import User, Project, TimeEntry, Task, Settings, Invoice, InvoiceItem app = create_app() @@ -18,13 +18,15 @@ def make_shell_context(): 'Project': Project, 'TimeEntry': TimeEntry, 'Task': Task, - 'Settings': Settings + 'Settings': Settings, + 'Invoice': Invoice, + 'InvoiceItem': InvoiceItem } @app.cli.command() def init_db(): """Initialize the database with tables and default data""" - from app.models import Settings + from app.models import Settings, User # Create all tables db.create_all() diff --git a/app/__init__.py b/app/__init__.py index 991bdd7..27f712f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -94,6 +94,7 @@ def create_app(config=None): from app.routes.api import api_bp from app.routes.analytics import analytics_bp from app.routes.tasks import tasks_bp + from app.routes.invoices import invoices_bp app.register_blueprint(auth_bp) app.register_blueprint(main_bp) @@ -104,6 +105,7 @@ def create_app(config=None): app.register_blueprint(api_bp) app.register_blueprint(analytics_bp) app.register_blueprint(tasks_bp) + app.register_blueprint(invoices_bp) # Register error handlers from app.utils.error_handlers import register_error_handlers diff --git a/app/models/__init__.py b/app/models/__init__.py index a03a9f5..348a2ab 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -3,5 +3,6 @@ from .project import Project from .time_entry import TimeEntry from .task import Task from .settings import Settings +from .invoice import Invoice, InvoiceItem -__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings'] +__all__ = ['User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem'] diff --git a/app/models/invoice.py b/app/models/invoice.py new file mode 100644 index 0000000..5dae137 --- /dev/null +++ b/app/models/invoice.py @@ -0,0 +1,177 @@ +from datetime import datetime +from decimal import Decimal +from app import db + +class Invoice(db.Model): + """Invoice model for client billing""" + + __tablename__ = 'invoices' + + id = db.Column(db.Integer, primary_key=True) + invoice_number = db.Column(db.String(50), unique=True, nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) + client_name = db.Column(db.String(200), nullable=False) + client_email = db.Column(db.String(200), nullable=True) + client_address = db.Column(db.Text, nullable=True) + + # Invoice details + issue_date = db.Column(db.Date, nullable=False, default=datetime.utcnow().date) + due_date = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), default='draft', nullable=False) # 'draft', 'sent', 'paid', 'overdue', 'cancelled' + + # Billing information + subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0) + tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) # Percentage + tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) + + # Notes and terms + notes = db.Column(db.Text, nullable=True) + terms = db.Column(db.Text, nullable=True) + + # Metadata + created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + project = db.relationship('Project', backref='invoices') + creator = db.relationship('User', backref='created_invoices') + items = db.relationship('InvoiceItem', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') + + def __init__(self, invoice_number, project_id, client_name, due_date, created_by, **kwargs): + self.invoice_number = invoice_number + self.project_id = project_id + self.client_name = client_name + self.due_date = due_date + self.created_by = created_by + + # Set optional fields + self.client_email = kwargs.get('client_email') + self.client_address = kwargs.get('client_address') + self.issue_date = kwargs.get('issue_date', datetime.utcnow().date()) + self.notes = kwargs.get('notes') + self.terms = kwargs.get('terms') + self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0))) + + def __repr__(self): + return f'' + + @property + def is_overdue(self): + """Check if invoice is overdue""" + return self.status in ['sent', 'overdue'] and datetime.utcnow().date() > self.due_date + + @property + def days_overdue(self): + """Calculate days overdue""" + if not self.is_overdue: + return 0 + return (datetime.utcnow().date() - self.due_date).days + + def calculate_totals(self): + """Calculate invoice totals from items""" + subtotal = sum(item.total_amount for item in self.items) + self.subtotal = subtotal + self.tax_amount = subtotal * (self.tax_rate / 100) + self.total_amount = subtotal + self.tax_amount + + # Update status if overdue + if self.status == 'sent' and self.is_overdue: + self.status = 'overdue' + + def to_dict(self): + """Convert invoice to dictionary for API responses""" + return { + 'id': self.id, + 'invoice_number': self.invoice_number, + 'project_id': self.project_id, + 'client_name': self.client_name, + 'client_email': self.client_email, + 'client_address': self.client_address, + 'issue_date': self.issue_date.isoformat() if self.issue_date else None, + 'due_date': self.due_date.isoformat() if self.due_date else None, + 'status': self.status, + 'subtotal': float(self.subtotal), + 'tax_rate': float(self.tax_rate), + 'tax_amount': float(self.tax_amount), + 'total_amount': float(self.total_amount), + 'notes': self.notes, + 'terms': self.terms, + 'created_by': self.created_by, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + 'is_overdue': self.is_overdue, + 'days_overdue': self.days_overdue + } + + @classmethod + def generate_invoice_number(cls): + """Generate a unique invoice number""" + from datetime import datetime + + # Format: INV-YYYYMMDD-XXX + today = datetime.utcnow() + date_prefix = today.strftime('%Y%m%d') + + # Find the next available number for today + existing = cls.query.filter( + cls.invoice_number.like(f'INV-{date_prefix}-%') + ).order_by(cls.invoice_number.desc()).first() + + if existing: + # Extract the number part and increment + try: + last_num = int(existing.invoice_number.split('-')[-1]) + next_num = last_num + 1 + except (ValueError, IndexError): + next_num = 1 + else: + next_num = 1 + + return f'INV-{date_prefix}-{next_num:03d}' + + +class InvoiceItem(db.Model): + """Invoice line item model""" + + __tablename__ = 'invoice_items' + + id = db.Column(db.Integer, primary_key=True) + invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) + + # Item details + description = db.Column(db.String(500), nullable=False) + quantity = db.Column(db.Numeric(10, 2), nullable=False, default=1) # Hours + unit_price = db.Column(db.Numeric(10, 2), nullable=False) # Hourly rate + total_amount = db.Column(db.Numeric(10, 2), nullable=False) + + # Time entry reference (optional) + time_entry_ids = db.Column(db.String(500), nullable=True) # Comma-separated IDs + + # Metadata + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + + def __init__(self, invoice_id, description, quantity, unit_price, time_entry_ids=None): + self.invoice_id = invoice_id + self.description = description + self.quantity = Decimal(str(quantity)) + self.unit_price = Decimal(str(unit_price)) + self.total_amount = self.quantity * self.unit_price + self.time_entry_ids = time_entry_ids + + def __repr__(self): + return f'' + + def to_dict(self): + """Convert invoice item to dictionary""" + return { + 'id': self.id, + 'invoice_id': self.invoice_id, + 'description': self.description, + 'quantity': float(self.quantity), + 'unit_price': float(self.unit_price), + 'total_amount': float(self.total_amount), + 'time_entry_ids': self.time_entry_ids, + 'created_at': self.created_at.isoformat() if self.created_at else None + } diff --git a/app/models/settings.py b/app/models/settings.py index 439c627..c8d7d50 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -1,6 +1,7 @@ from datetime import datetime from app import db from app.config import Config +import os class Settings(db.Model): """Settings model for system configuration""" @@ -17,6 +18,23 @@ class Settings(db.Model): backup_retention_days = db.Column(db.Integer, default=30, nullable=False) backup_time = db.Column(db.String(5), default='02:00', nullable=False) # HH:MM format export_delimiter = db.Column(db.String(1), default=',', nullable=False) + + # Company branding for invoices + company_name = db.Column(db.String(200), default='Your Company Name', nullable=False) + company_address = db.Column(db.Text, default='Your Company Address', nullable=False) + company_email = db.Column(db.String(200), default='info@yourcompany.com', nullable=False) + company_phone = db.Column(db.String(50), default='+1 (555) 123-4567', nullable=False) + company_website = db.Column(db.String(200), default='www.yourcompany.com', nullable=False) + company_logo_filename = db.Column(db.String(255), default='', nullable=True) # Changed from company_logo_path + company_tax_id = db.Column(db.String(100), default='', nullable=True) + company_bank_info = db.Column(db.Text, default='', nullable=True) + + # Invoice defaults + invoice_prefix = db.Column(db.String(10), default='INV', nullable=False) + invoice_start_number = db.Column(db.Integer, default=1000, nullable=False) + invoice_terms = db.Column(db.Text, default='Payment is due within 30 days of invoice date.', nullable=False) + invoice_notes = db.Column(db.Text, default='Thank you for your business!', nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -31,10 +49,54 @@ class Settings(db.Model): self.backup_retention_days = kwargs.get('backup_retention_days', Config.BACKUP_RETENTION_DAYS) self.backup_time = kwargs.get('backup_time', Config.BACKUP_TIME) self.export_delimiter = kwargs.get('export_delimiter', ',') + + # Set company branding defaults + self.company_name = kwargs.get('company_name', 'Your Company Name') + self.company_address = kwargs.get('company_address', 'Your Company Address') + self.company_email = kwargs.get('company_email', 'info@yourcompany.com') + self.company_phone = kwargs.get('company_phone', '+1 (555) 123-4567') + self.company_website = kwargs.get('company_website', 'www.yourcompany.com') + self.company_logo_filename = kwargs.get('company_logo_filename', '') + self.company_tax_id = kwargs.get('company_tax_id', '') + self.company_bank_info = kwargs.get('company_bank_info', '') + + # Set invoice defaults + self.invoice_prefix = kwargs.get('invoice_prefix', 'INV') + self.invoice_start_number = kwargs.get('invoice_start_number', 1000) + self.invoice_terms = kwargs.get('invoice_terms', 'Payment is due within 30 days of invoice date.') + self.invoice_notes = kwargs.get('invoice_notes', 'Thank you for your business!') def __repr__(self): return f'' + def get_logo_url(self): + """Get the full URL for the company logo""" + if self.company_logo_filename: + return f'/uploads/logos/{self.company_logo_filename}' + return None + + def get_logo_path(self): + """Get the full file system path for the company logo""" + if not self.company_logo_filename: + return None + + try: + from flask import current_app + upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'logos') + return os.path.join(upload_folder, self.company_logo_filename) + except RuntimeError: + # current_app not available (e.g., during testing or initialization) + # Fallback to a relative path + return os.path.join('app', 'static', 'uploads', 'logos', self.company_logo_filename) + + def has_logo(self): + """Check if company has a logo uploaded""" + if not self.company_logo_filename: + return False + + logo_path = self.get_logo_path() + return logo_path and os.path.exists(logo_path) + def to_dict(self): """Convert settings to dictionary for API responses""" return { @@ -48,6 +110,20 @@ class Settings(db.Model): 'backup_retention_days': self.backup_retention_days, 'backup_time': self.backup_time, 'export_delimiter': self.export_delimiter, + 'company_name': self.company_name, + 'company_address': self.company_address, + 'company_email': self.company_email, + 'company_phone': self.company_phone, + 'company_website': self.company_website, + 'company_logo_filename': self.company_logo_filename, + 'company_logo_url': self.get_logo_url(), + 'has_logo': self.has_logo(), + 'company_tax_id': self.company_tax_id, + 'company_bank_info': self.company_bank_info, + 'invoice_prefix': self.invoice_prefix, + 'invoice_start_number': self.invoice_start_number, + 'invoice_terms': self.invoice_terms, + 'invoice_notes': self.invoice_notes, 'created_at': self.created_at.isoformat() if self.created_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None } diff --git a/app/routes/admin.py b/app/routes/admin.py index 645a5d8..3939668 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,12 +1,18 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory from flask_login import login_required, current_user from app import db from app.models import User, Project, TimeEntry, Settings from datetime import datetime from sqlalchemy import text +import os +from werkzeug.utils import secure_filename +import uuid admin_bp = Blueprint('admin', __name__) +# Allowed file extensions for logos +ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'} + def admin_required(f): """Decorator to require admin access""" from functools import wraps @@ -18,6 +24,17 @@ def admin_required(f): return f(*args, **kwargs) return decorated_function +def allowed_logo_file(filename): + """Check if the uploaded file has an allowed extension""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_LOGO_EXTENSIONS + +def get_upload_folder(): + """Get the upload folder path for logos""" + upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'logos') + os.makedirs(upload_folder, exist_ok=True) + return upload_folder + @admin_bp.route('/admin') @login_required @admin_required @@ -106,25 +123,31 @@ def create_user(): @login_required @admin_required def edit_user(user_id): - """Edit user details""" + """Edit an existing user""" user = User.query.get_or_404(user_id) if request.method == 'POST': + username = request.form.get('username', '').strip().lower() role = request.form.get('role', 'user') is_active = request.form.get('is_active') == 'on' - # Don't allow deactivating the last admin - if not is_active and user.is_admin: - admin_count = User.query.filter_by(role='admin', is_active=True).count() - if admin_count <= 1: - flash('Cannot deactivate the last administrator', 'error') - return render_template('admin/user_form.html', user=user) + if not username: + flash('Username is required', 'error') + return render_template('admin/user_form.html', user=user) + # Check if username is already taken by another user + existing_user = User.query.filter_by(username=username).first() + if existing_user and existing_user.id != user.id: + flash('Username already exists', 'error') + return render_template('admin/user_form.html', user=user) + + # Update user + user.username = username user.role = role user.is_active = is_active db.session.commit() - flash(f'User "{user.username}" updated successfully', 'success') + flash(f'User "{username}" updated successfully', 'success') return redirect(url_for('admin.list_users')) return render_template('admin/user_form.html', user=user) @@ -172,7 +195,7 @@ def settings(): flash(f'Invalid timezone: {timezone}', 'error') return render_template('admin/settings.html', settings=settings_obj) - # Update settings + # Update basic settings settings_obj.timezone = timezone settings_obj.currency = request.form.get('currency', 'EUR') settings_obj.rounding_minutes = int(request.form.get('rounding_minutes', 1)) @@ -183,12 +206,97 @@ def settings(): settings_obj.backup_time = request.form.get('backup_time', '02:00') settings_obj.export_delimiter = request.form.get('export_delimiter', ',') + # Update company branding settings + settings_obj.company_name = request.form.get('company_name', 'Your Company Name') + settings_obj.company_address = request.form.get('company_address', 'Your Company Address') + settings_obj.company_email = request.form.get('company_email', 'info@yourcompany.com') + settings_obj.company_phone = request.form.get('company_phone', '+1 (555) 123-4567') + settings_obj.company_website = request.form.get('company_website', 'www.yourcompany.com') + settings_obj.company_tax_id = request.form.get('company_tax_id', '') + settings_obj.company_bank_info = request.form.get('company_bank_info', '') + + # Update invoice defaults + settings_obj.invoice_prefix = request.form.get('invoice_prefix', 'INV') + settings_obj.invoice_start_number = int(request.form.get('invoice_start_number', 1000)) + settings_obj.invoice_terms = request.form.get('invoice_terms', 'Payment is due within 30 days of invoice date.') + settings_obj.invoice_notes = request.form.get('invoice_notes', 'Thank you for your business!') + db.session.commit() flash('Settings updated successfully', 'success') return redirect(url_for('admin.settings')) return render_template('admin/settings.html', settings=settings_obj) +@admin_bp.route('/admin/upload-logo', methods=['POST']) +@login_required +@admin_required +def upload_logo(): + """Upload company logo""" + if 'logo' not in request.files: + flash('No logo file selected', 'error') + return redirect(url_for('admin.settings')) + + file = request.files['logo'] + if file.filename == '': + flash('No logo file selected', 'error') + return redirect(url_for('admin.settings')) + + if file and allowed_logo_file(file.filename): + # Generate unique filename + file_extension = file.filename.rsplit('.', 1)[1].lower() + unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}" + + # Save file + upload_folder = get_upload_folder() + file_path = os.path.join(upload_folder, unique_filename) + file.save(file_path) + + # Update settings + settings_obj = Settings.get_settings() + + # Remove old logo if it exists + if settings_obj.company_logo_filename: + old_logo_path = os.path.join(upload_folder, settings_obj.company_logo_filename) + if os.path.exists(old_logo_path): + try: + os.remove(old_logo_path) + except OSError: + pass # Ignore errors when removing old file + + settings_obj.company_logo_filename = unique_filename + db.session.commit() + + flash('Company logo uploaded successfully', 'success') + else: + flash('Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP', 'error') + + return redirect(url_for('admin.settings')) + +@admin_bp.route('/admin/remove-logo', methods=['POST']) +@login_required +@admin_required +def remove_logo(): + """Remove company logo""" + settings_obj = Settings.get_settings() + + if settings_obj.company_logo_filename: + # Remove file from filesystem + logo_path = settings_obj.get_logo_path() + if logo_path and os.path.exists(logo_path): + try: + os.remove(logo_path) + except OSError: + pass # Ignore errors when removing file + + # Clear filename from database + settings_obj.company_logo_filename = '' + db.session.commit() + flash('Company logo removed successfully', 'success') + else: + flash('No logo to remove', 'info') + + return redirect(url_for('admin.settings')) + @admin_bp.route('/admin/backup') @login_required @admin_required diff --git a/app/routes/invoices.py b/app/routes/invoices.py new file mode 100644 index 0000000..7cde063 --- /dev/null +++ b/app/routes/invoices.py @@ -0,0 +1,477 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file +from flask_login import login_required, current_user +from app import db +from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings +from datetime import datetime, timedelta, date +from decimal import Decimal +import io +import csv +import json + +invoices_bp = Blueprint('invoices', __name__) + +@invoices_bp.route('/invoices') +@login_required +def list_invoices(): + """List all invoices""" + # Get invoices (scope by user unless admin) + if current_user.is_admin: + invoices = Invoice.query.order_by(Invoice.created_at.desc()).all() + else: + invoices = Invoice.query.filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all() + + # Get summary statistics + total_invoices = len(invoices) + total_amount = sum(invoice.total_amount for invoice in invoices) + paid_amount = sum(invoice.total_amount for invoice in invoices if invoice.status == 'paid') + overdue_amount = sum(invoice.total_amount for invoice in invoices if invoice.status == 'overdue') + + summary = { + 'total_invoices': total_invoices, + 'total_amount': float(total_amount), + 'paid_amount': float(paid_amount), + 'overdue_amount': float(overdue_amount), + 'outstanding_amount': float(total_amount - paid_amount) + } + + return render_template('invoices/list.html', invoices=invoices, summary=summary) + +@invoices_bp.route('/invoices/create', methods=['GET', 'POST']) +@login_required +def create_invoice(): + """Create a new invoice""" + if request.method == 'POST': + # Get form data + project_id = request.form.get('project_id', type=int) + client_name = request.form.get('client_name', '').strip() + client_email = request.form.get('client_email', '').strip() + client_address = request.form.get('client_address', '').strip() + due_date_str = request.form.get('due_date', '').strip() + tax_rate = request.form.get('tax_rate', '0').strip() + notes = request.form.get('notes', '').strip() + terms = request.form.get('terms', '').strip() + + # Validate required fields + if not project_id or not client_name or not due_date_str: + flash('Project, client name, and due date are required', 'error') + return render_template('invoices/create.html') + + try: + due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + except ValueError: + flash('Invalid due date format', 'error') + return render_template('invoices/create.html') + + try: + tax_rate = Decimal(tax_rate) + except ValueError: + flash('Invalid tax rate format', 'error') + return render_template('invoices/create.html') + + # Get project + project = Project.query.get(project_id) + if not project: + flash('Selected project not found', 'error') + return render_template('invoices/create.html') + + # Generate invoice number + invoice_number = Invoice.generate_invoice_number() + + # Create invoice + invoice = Invoice( + invoice_number=invoice_number, + project_id=project_id, + client_name=client_name, + due_date=due_date, + created_by=current_user.id, + client_email=client_email, + client_address=client_address, + tax_rate=tax_rate, + notes=notes, + terms=terms + ) + + db.session.add(invoice) + db.session.commit() + + flash(f'Invoice {invoice_number} created successfully', 'success') + return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) + + # GET request - show form + projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all() + settings = Settings.get_settings() + + # Set default due date to 30 days from now + default_due_date = (datetime.utcnow() + timedelta(days=30)).strftime('%Y-%m-%d') + + return render_template('invoices/create.html', + projects=projects, + settings=settings, + default_due_date=default_due_date) + +@invoices_bp.route('/invoices/') +@login_required +def view_invoice(invoice_id): + """View invoice details""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to view this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + return render_template('invoices/view.html', invoice=invoice) + +@invoices_bp.route('/invoices//edit', methods=['GET', 'POST']) +@login_required +def edit_invoice(invoice_id): + """Edit invoice""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to edit this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + if request.method == 'POST': + # Update invoice details + invoice.client_name = request.form.get('client_name', '').strip() + invoice.client_email = request.form.get('client_email', '').strip() + invoice.client_address = request.form.get('client_address', '').strip() + invoice.due_date = datetime.strptime(request.form.get('due_date'), '%Y-%m-%d').date() + invoice.tax_rate = Decimal(request.form.get('tax_rate', '0')) + invoice.notes = request.form.get('notes', '').strip() + invoice.terms = request.form.get('terms', '').strip() + + # Update items + item_ids = request.form.getlist('item_id[]') + descriptions = request.form.getlist('description[]') + quantities = request.form.getlist('quantity[]') + unit_prices = request.form.getlist('unit_price[]') + + # Remove existing items + invoice.items.delete() + + # Add new items + for i in range(len(descriptions)): + if descriptions[i].strip() and quantities[i] and unit_prices[i]: + try: + quantity = Decimal(quantities[i]) + unit_price = Decimal(unit_prices[i]) + + item = InvoiceItem( + invoice_id=invoice.id, + description=descriptions[i].strip(), + quantity=quantity, + unit_price=unit_price + ) + db.session.add(item) + except ValueError: + flash(f'Invalid quantity or price for item {i+1}', 'error') + continue + + # Calculate totals + invoice.calculate_totals() + db.session.commit() + + flash('Invoice updated successfully', 'success') + return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) + + # GET request - show edit form + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + return render_template('invoices/edit.html', invoice=invoice, projects=projects) + +@invoices_bp.route('/invoices//status', methods=['POST']) +@login_required +def update_invoice_status(invoice_id): + """Update invoice status""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + return jsonify({'error': 'Permission denied'}), 403 + + new_status = request.form.get('new_status') + if new_status not in ['draft', 'sent', 'paid', 'cancelled']: + return jsonify({'error': 'Invalid status'}), 400 + + invoice.status = new_status + db.session.commit() + + return jsonify({'success': True, 'status': new_status}) + +@invoices_bp.route('/invoices//delete', methods=['POST']) +@login_required +def delete_invoice(invoice_id): + """Delete invoice""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to delete this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + invoice_number = invoice.invoice_number + db.session.delete(invoice) + db.session.commit() + + flash(f'Invoice {invoice_number} deleted successfully', 'success') + return redirect(url_for('invoices.list_invoices')) + +@invoices_bp.route('/invoices//generate-from-time', methods=['GET', 'POST']) +@login_required +def generate_from_time(invoice_id): + """Generate invoice items from time entries""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to edit this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + if request.method == 'POST': + # Get selected time entries + selected_entries = request.form.getlist('time_entries[]') + if not selected_entries: + flash('No time entries selected', 'error') + return redirect(url_for('invoices.generate_from_time', invoice_id=invoice.id)) + + # Clear existing items + invoice.items.delete() + + # Group time entries by task/project and create invoice items + time_entries = TimeEntry.query.filter(TimeEntry.id.in_(selected_entries)).all() + + # Group by task (if available) or project + grouped_entries = {} + for entry in time_entries: + if entry.task_id: + key = f"task_{entry.task_id}" + if key not in grouped_entries: + grouped_entries[key] = { + 'description': f"Task: {entry.task.name if entry.task else 'Unknown Task'}", + 'entries': [], + 'total_hours': 0 + } + else: + key = f"project_{entry.project_id}" + if key not in grouped_entries: + grouped_entries[key] = { + 'description': f"Project: {entry.project.name}", + 'entries': [], + 'total_hours': 0 + } + + grouped_entries[key]['entries'].append(entry) + grouped_entries[key]['total_hours'] += entry.duration_hours + + # Create invoice items + for group in grouped_entries.values(): + # Use project hourly rate or default + hourly_rate = invoice.project.hourly_rate or Decimal('0') + + item = InvoiceItem( + invoice_id=invoice.id, + description=group['description'], + quantity=group['total_hours'], + unit_price=hourly_rate, + time_entry_ids=','.join(str(entry.id) for entry in group['entries']) + ) + db.session.add(item) + + # Calculate totals + invoice.calculate_totals() + db.session.commit() + + flash('Invoice items generated from time entries successfully', 'success') + return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) + + # GET request - show time entry selection + # Get unbilled time entries for this project + time_entries = TimeEntry.query.filter( + TimeEntry.project_id == invoice.project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.billable == True + ).order_by(TimeEntry.start_time.desc()).all() + + # Filter out entries already billed in other invoices + unbilled_entries = [] + for entry in time_entries: + # Check if this entry is already billed in another invoice + already_billed = False + for other_invoice in invoice.project.invoices: + if other_invoice.id != invoice.id: + for item in other_invoice.items: + if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(','): + already_billed = True + break + if already_billed: + break + + if not already_billed: + unbilled_entries.append(entry) + + # Calculate total available hours + total_available_hours = sum(entry.duration_hours for entry in unbilled_entries) + + # Get currency from settings + settings = Settings.get_settings() + currency = settings.currency if settings else 'USD' + + return render_template('invoices/generate_from_time.html', + invoice=invoice, + time_entries=unbilled_entries, + total_available_hours=total_available_hours, + currency=currency) + +@invoices_bp.route('/invoices//export/csv') +@login_required +def export_invoice_csv(invoice_id): + """Export invoice as CSV""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to export this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + # Create CSV output + output = io.StringIO() + writer = csv.writer(output) + + # Write header + writer.writerow(['Invoice Number', invoice.invoice_number]) + writer.writerow(['Client', invoice.client_name]) + writer.writerow(['Issue Date', invoice.issue_date.strftime('%Y-%m-%d')]) + writer.writerow(['Due Date', invoice.due_date.strftime('%Y-%m-%d')]) + writer.writerow(['Status', invoice.status]) + writer.writerow([]) + + # Write items + writer.writerow(['Description', 'Quantity (Hours)', 'Unit Price', 'Total Amount']) + for item in invoice.items: + writer.writerow([ + item.description, + float(item.quantity), + float(item.unit_price), + float(item.total_amount) + ]) + + writer.writerow([]) + writer.writerow(['Subtotal', '', '', float(invoice.subtotal)]) + writer.writerow(['Tax Rate', '', '', f'{float(invoice.tax_rate)}%']) + writer.writerow(['Tax Amount', '', '', float(invoice.tax_amount)]) + writer.writerow(['Total Amount', '', '', float(invoice.total_amount)]) + + output.seek(0) + + filename = f'invoice_{invoice.invoice_number}.csv' + + return send_file( + io.BytesIO(output.getvalue().encode('utf-8')), + mimetype='text/csv', + as_attachment=True, + download_name=filename + ) + +@invoices_bp.route('/invoices//export/pdf') +@login_required +def export_invoice_pdf(invoice_id): + """Export invoice as PDF""" + invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and invoice.created_by != current_user.id: + flash('You do not have permission to export this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + try: + from app.utils.pdf_generator import InvoicePDFGenerator + + # Generate PDF + pdf_generator = InvoicePDFGenerator(invoice) + pdf_bytes = pdf_generator.generate_pdf() + + filename = f'invoice_{invoice.invoice_number}.pdf' + + return send_file( + io.BytesIO(pdf_bytes), + mimetype='application/pdf', + as_attachment=True, + download_name=filename + ) + + except ImportError: + flash('PDF generation is not available. Please install WeasyPrint.', 'error') + return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) + except Exception as e: + # Try fallback PDF generator + try: + from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback + + flash('WeasyPrint failed, using fallback PDF generator. PDF quality may be reduced.', 'warning') + + # Generate PDF using fallback + pdf_generator = InvoicePDFGeneratorFallback(invoice) + pdf_bytes = pdf_generator.generate_pdf() + + filename = f'invoice_{invoice.invoice_number}.pdf' + + return send_file( + io.BytesIO(pdf_bytes), + mimetype='application/pdf', + as_attachment=True, + download_name=filename + ) + + except Exception as fallback_error: + flash(f'PDF generation failed: {str(e)}. Fallback also failed: {str(fallback_error)}', 'error') + return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) + +@invoices_bp.route('/invoices//duplicate') +@login_required +def duplicate_invoice(invoice_id): + """Duplicate an existing invoice""" + original_invoice = Invoice.query.get_or_404(invoice_id) + + # Check access permissions + if not current_user.is_admin and original_invoice.created_by != current_user.id: + flash('You do not have permission to duplicate this invoice', 'error') + return redirect(url_for('invoices.list_invoices')) + + # Generate new invoice number + new_invoice_number = Invoice.generate_invoice_number() + + # Create new invoice + new_invoice = Invoice( + invoice_number=new_invoice_number, + project_id=original_invoice.project_id, + client_name=original_invoice.client_name, + client_email=original_invoice.client_email, + client_address=original_invoice.client_address, + due_date=original_invoice.due_date + timedelta(days=30), # 30 days from original due date + created_by=current_user.id, + tax_rate=original_invoice.tax_rate, + notes=original_invoice.notes, + terms=original_invoice.terms + ) + + db.session.add(new_invoice) + db.session.commit() + + # Duplicate items + for original_item in original_invoice.items: + new_item = InvoiceItem( + invoice_id=new_invoice.id, + description=original_item.description, + quantity=original_item.quantity, + unit_price=original_item.unit_price + ) + db.session.add(new_item) + + # Calculate totals + new_invoice.calculate_totals() + db.session.commit() + + flash(f'Invoice {new_invoice_number} created as duplicate', 'success') + return redirect(url_for('invoices.edit_invoice', invoice_id=new_invoice.id)) diff --git a/app/static/mobile.css b/app/static/mobile.css index 619e925..38e4758 100644 --- a/app/static/mobile.css +++ b/app/static/mobile.css @@ -1,23 +1,29 @@ -/* Mobile-First CSS for TimeTracker */ +/* Enhanced Mobile-First CSS for TimeTracker */ /* Mobile-specific variables */ :root { - --mobile-touch-target: 44px; - --mobile-nav-height: 60px; - --mobile-card-padding: 1rem; - --mobile-button-height: 48px; - --mobile-input-height: 48px; + --mobile-touch-target: 52px; + --mobile-nav-height: 70px; + --mobile-card-padding: 1.25rem; + --mobile-button-height: 52px; + --mobile-input-height: 56px; + --mobile-section-spacing: 1.5rem; + --mobile-card-spacing: 1rem; + --mobile-border-radius: 12px; + --mobile-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + --mobile-shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.15); } -/* Mobile-specific improvements */ +/* Enhanced Mobile-specific improvements */ @media (max-width: 768px) { /* Container improvements */ .container, .container-fluid { padding-left: 1rem; padding-right: 1rem; + max-width: 100%; } - /* Row and column improvements */ + /* Enhanced Row and Column Layout */ .row { margin-left: -0.5rem; margin-right: -0.5rem; @@ -26,13 +32,25 @@ .col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 { padding-left: 0.5rem; padding-right: 0.5rem; + margin-bottom: var(--mobile-card-spacing); } - /* Card improvements */ + .col:last-child { + margin-bottom: 0; + } + + /* Enhanced Card Layout */ .card { - margin-bottom: 1rem; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: var(--mobile-card-spacing); + border-radius: var(--mobile-border-radius); + box-shadow: var(--mobile-shadow); + border: 1px solid #e2e8f0; + transition: all 0.3s ease; + } + + .card:hover { + box-shadow: var(--mobile-shadow-hover); + transform: translateY(-2px); } .card-body { @@ -40,22 +58,31 @@ } .card-header { - padding: 1rem var(--mobile-card-padding); + padding: 1.25rem var(--mobile-card-padding); + border-bottom: 1px solid #e2e8f0; + background: #f8fafc; } - /* Button improvements */ + /* Enhanced Button Layout */ .btn { min-height: var(--mobile-button-height); - padding: 0.875rem 1.5rem; + padding: 1rem 1.5rem; font-size: 1rem; - border-radius: 8px; + border-radius: var(--mobile-border-radius); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; text-decoration: none; - transition: all 0.2s ease; + transition: all 0.3s ease; -webkit-tap-highlight-color: transparent; + width: 100%; + margin-bottom: 0.75rem; + gap: 0.5rem; + } + + .btn:last-child { + margin-bottom: 0; } .btn:active { @@ -63,54 +90,54 @@ } .btn-sm { - min-height: 40px; - padding: 0.75rem 1.25rem; + min-height: 44px; + padding: 0.875rem 1.25rem; font-size: 0.9rem; } .btn-lg { - min-height: 56px; - padding: 1.125rem 2rem; + min-height: 60px; + padding: 1.25rem 2rem; font-size: 1.125rem; } - /* Button group improvements */ + /* Enhanced Button Group Layout */ .btn-group { display: flex; flex-direction: column; width: 100%; + gap: 0.75rem; } .btn-group .btn { - border-radius: 8px !important; - margin-bottom: 0.5rem; + border-radius: var(--mobile-border-radius) !important; + margin-bottom: 0; width: 100%; } - .btn-group .btn:last-child { - margin-bottom: 0; - } - - /* Form improvements */ + /* Enhanced Form Layout */ .form-control, .form-select { min-height: var(--mobile-input-height); - padding: 0.875rem 1rem; + padding: 1rem 1.25rem; font-size: 16px; /* Prevents zoom on iOS */ - border-radius: 8px; + border-radius: var(--mobile-border-radius); border: 2px solid #e2e8f0; - transition: all 0.2s ease; + transition: all 0.3s ease; + background: white; + width: 100%; } .form-control:focus, .form-select:focus { border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); outline: none; + transform: translateY(-1px); } .form-label { font-weight: 600; color: #1e293b; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; font-size: 0.95rem; display: block; } @@ -118,14 +145,15 @@ .form-text { font-size: 0.875rem; color: #64748b; - margin-top: 0.25rem; + margin-top: 0.5rem; } - /* Table improvements for mobile */ + /* Enhanced Table Layout for Mobile */ .table-responsive { border: none; - border-radius: 12px; + border-radius: var(--mobile-border-radius); overflow: hidden; + margin-bottom: var(--mobile-card-spacing); } .table { @@ -147,10 +175,16 @@ display: block; margin-bottom: 1rem; border: 1px solid #e2e8f0; - border-radius: 12px; + border-radius: var(--mobile-border-radius); background: white; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--mobile-shadow); overflow: hidden; + transition: all 0.3s ease; + } + + .table tr:hover { + box-shadow: var(--mobile-shadow-hover); + transform: translateY(-1px); } .table td { @@ -160,6 +194,7 @@ border: none; border-bottom: 1px solid #f1f5f9; position: relative; + background: white; } .table td:last-child { @@ -188,14 +223,17 @@ display: none; } - /* Navigation improvements */ + /* Enhanced Navigation Layout */ .navbar { min-height: var(--mobile-nav-height); padding: 0.75rem 0; + background: white !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .navbar-brand { - font-size: 1.2rem; + font-size: 1.25rem; + font-weight: 700; } .navbar-toggler { @@ -203,38 +241,53 @@ padding: 0.5rem; min-height: var(--mobile-touch-target); min-width: var(--mobile-touch-target); - border-radius: 8px; + border-radius: var(--mobile-border-radius); + background: #f8fafc; } .navbar-toggler:focus { - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); } .navbar-collapse { background: white; border-top: 1px solid #e2e8f0; - margin-top: 0.5rem; + margin-top: 0.75rem; padding: 1rem 0; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - border-radius: 0 0 12px 12px; + border-radius: 0 0 var(--mobile-border-radius) var(--mobile-border-radius); } .navbar-nav .nav-link { padding: 1rem 1.5rem; margin: 0.25rem 0; - border-radius: 8px; + border-radius: var(--mobile-border-radius); font-size: 1.1rem; min-height: var(--mobile-touch-target); display: flex; align-items: center; + gap: 0.75rem; + transition: all 0.3s ease; + } + + .navbar-nav .nav-link:hover { + background: #f8fafc; + transform: translateX(4px); + } + + .navbar-nav .nav-link.active { + background: #3b82f6; + color: white !important; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } .navbar-nav .nav-link i { width: 24px; text-align: center; - margin-right: 0.75rem; + font-size: 1.1rem; } + /* Enhanced Dropdown Layout */ .dropdown-menu { position: static !important; float: none; @@ -243,77 +296,82 @@ border: none; box-shadow: none; background: #f8fafc; - border-radius: 8px; - margin-top: 0.5rem; + border-radius: var(--mobile-border-radius); + margin-top: 0.75rem; + padding: 0.5rem; } .dropdown-item { - padding: 0.75rem 2rem; - border-radius: 8px; - margin: 0.25rem 0.5rem; + padding: 0.875rem 1.5rem; + border-radius: var(--mobile-border-radius); + margin: 0.25rem 0; min-height: var(--mobile-touch-target); display: flex; align-items: center; + gap: 0.75rem; + transition: all 0.3s ease; } - /* Modal improvements */ + .dropdown-item:hover { + background: #e2e8f0; + transform: translateX(4px); + } + + /* Enhanced Modal Layout */ .modal-dialog { - margin: 0.5rem; - max-width: calc(100% - 1rem); + margin: 0.75rem; + max-width: calc(100% - 1.5rem); } .modal-content { - border-radius: 12px; + border-radius: var(--mobile-border-radius); border: none; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); } .modal-header, .modal-body, .modal-footer { - padding: 1rem; + padding: 1.25rem; } .modal-footer { flex-direction: column; + gap: 0.75rem; } .modal-footer .btn { width: 100%; - margin-bottom: 0.5rem; - } - - .modal-footer .btn:last-child { margin-bottom: 0; } - /* Typography improvements */ - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } - h4 { font-size: 1.125rem; } - h5 { font-size: 1rem; } - h6 { font-size: 0.95rem; } + /* Enhanced Typography */ + h1 { font-size: 1.875rem; line-height: 1.2; } + h2 { font-size: 1.625rem; line-height: 1.2; } + h3 { font-size: 1.375rem; line-height: 1.3; } + h4 { font-size: 1.125rem; line-height: 1.3; } + h5 { font-size: 1rem; line-height: 1.4; } + h6 { font-size: 0.95rem; line-height: 1.4; } - /* Spacing improvements */ - .mb-4 { margin-bottom: 1.5rem !important; } - .mb-3 { margin-bottom: 1rem !important; } + /* Enhanced Spacing */ + .mb-4 { margin-bottom: var(--mobile-section-spacing) !important; } + .mb-3 { margin-bottom: var(--mobile-card-spacing) !important; } .mb-2 { margin-bottom: 0.75rem !important; } - .py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } - .py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } + .py-4 { padding-top: var(--mobile-section-spacing) !important; padding-bottom: var(--mobile-section-spacing) !important; } + .py-3 { padding-top: var(--mobile-card-spacing) !important; padding-bottom: var(--mobile-card-spacing) !important; } - /* Utility classes */ + .px-4 { padding-left: var(--mobile-card-padding) !important; padding-right: var(--mobile-card-padding) !important; } + .px-3 { padding-left: 1rem !important; padding-right: 1rem !important; } + + /* Enhanced Utility Classes */ .mobile-stack { display: flex; flex-direction: column; + gap: 0.75rem; } .mobile-stack .btn { - margin-bottom: 0.5rem; - width: 100%; - } - - .mobile-stack .btn:last-child { margin-bottom: 0; + width: 100%; } .touch-target { @@ -321,11 +379,17 @@ min-width: var(--mobile-touch-target); } - /* Mobile-specific components */ + /* Enhanced Mobile Components */ .mobile-card { - margin-bottom: 1rem; - border-radius: 12px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: var(--mobile-card-spacing); + border-radius: var(--mobile-border-radius); + box-shadow: var(--mobile-shadow); + transition: all 0.3s ease; + } + + .mobile-card:hover { + box-shadow: var(--mobile-shadow-hover); + transform: translateY(-2px); } .mobile-btn { @@ -334,20 +398,27 @@ padding: 1rem 1.5rem; font-size: 1rem; min-height: var(--mobile-button-height); + border-radius: var(--mobile-border-radius); } .mobile-btn:last-child { margin-bottom: 0; } - /* Mobile table improvements */ + /* Enhanced Mobile Table Layout */ .mobile-table-row { background: white; border: 1px solid #e2e8f0; - border-radius: 12px; - margin-bottom: 1rem; + border-radius: var(--mobile-border-radius); + margin-bottom: var(--mobile-card-spacing); padding: 1rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--mobile-shadow); + transition: all 0.3s ease; + } + + .mobile-table-row:hover { + box-shadow: var(--mobile-shadow-hover); + transform: translateY(-1px); } .mobile-table-row .row { @@ -355,12 +426,14 @@ } .mobile-table-row .col { - padding: 0.5rem 0; + padding: 0.75rem 0; border-bottom: 1px solid #f1f5f9; + margin-bottom: 0.5rem; } .mobile-table-row .col:last-child { border-bottom: none; + margin-bottom: 0; } .mobile-table-row .col:before { @@ -368,52 +441,70 @@ font-weight: 600; color: #1e293b; display: block; - margin-bottom: 0.25rem; + margin-bottom: 0.5rem; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.5px; color: #64748b; } - /* Mobile form improvements */ + /* Enhanced Mobile Form Layout */ .mobile-form-group { margin-bottom: 1.5rem; } .mobile-form-group .form-label { display: block; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; font-weight: 600; + color: #1e293b; } .mobile-form-group .form-control, .mobile-form-group .form-select { width: 100%; + margin-bottom: 0.5rem; } - /* Mobile navigation improvements */ + .mobile-form-group .form-text { + margin-top: 0.25rem; + font-size: 0.8rem; + } + + /* Enhanced Mobile Navigation */ .mobile-nav-item { padding: 1rem 1.5rem; - border-radius: 8px; + border-radius: var(--mobile-border-radius); margin: 0.25rem 0; - transition: all 0.2s ease; + transition: all 0.3s ease; min-height: var(--mobile-touch-target); display: flex; align-items: center; + gap: 0.75rem; + background: white; + border: 1px solid #e2e8f0; } .mobile-nav-item:hover { background: #f8fafc; + transform: translateX(4px); + box-shadow: var(--mobile-shadow); } .mobile-nav-item.active { background: #3b82f6; color: white; + border-color: #3b82f6; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } - /* Mobile-specific animations */ + /* Enhanced Mobile Animations */ .mobile-fade-in { - animation: mobileFadeIn 0.3s ease-in; + animation: mobileFadeIn 0.4s ease-out; + } + + .mobile-slide-up { + animation: mobileSlideUp 0.4s ease-out; } @keyframes mobileFadeIn { @@ -427,22 +518,33 @@ } } - /* Mobile-specific shadows */ + @keyframes mobileSlideUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + /* Enhanced Mobile Shadows */ .mobile-shadow { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + box-shadow: var(--mobile-shadow); } .mobile-shadow-hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + box-shadow: var(--mobile-shadow-hover); } - /* Mobile-specific borders */ + /* Enhanced Mobile Borders */ .mobile-border { border: 1px solid #e2e8f0; - border-radius: 12px; + border-radius: var(--mobile-border-radius); } - /* Mobile-specific backgrounds */ + /* Enhanced Mobile Backgrounds */ .mobile-bg-light { background-color: #f8fafc; } @@ -451,7 +553,7 @@ background-color: white; } - /* Mobile-specific text colors */ + /* Enhanced Mobile Text Colors */ .mobile-text-primary { color: #1e293b !important; } @@ -464,21 +566,41 @@ color: #64748b !important; } - /* Mobile-specific spacing utilities */ + /* Enhanced Mobile Spacing Utilities */ .mobile-p-0 { padding: 0 !important; } .mobile-p-1 { padding: 0.25rem !important; } .mobile-p-2 { padding: 0.5rem !important; } .mobile-p-3 { padding: 1rem !important; } - .mobile-p-4 { padding: 1.5rem !important; } + .mobile-p-4 { padding: 1.25rem !important; } .mobile-m-0 { margin: 0 !important; } .mobile-m-1 { margin: 0.25rem !important; } .mobile-m-2 { margin: 0.5rem !important; } .mobile-m-3 { margin: 1rem !important; } - .mobile-m-4 { margin: 1.5rem !important; } + .mobile-m-4 { margin: 1.25rem !important; } + + /* Enhanced Mobile Grid Layout */ + .mobile-grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + + .mobile-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; + } + + @media (max-width: 480px) { + .mobile-grid-2, + .mobile-grid-3 { + grid-template-columns: 1fr; + } + } } -/* Small mobile devices */ +/* Enhanced Small Mobile Devices */ @media (max-width: 480px) { .container, .container-fluid { padding-left: 0.75rem; @@ -486,17 +608,22 @@ } .card-body { - padding: 0.875rem; + padding: 1rem; + } + + .card-header { + padding: 1rem 1rem; } .btn { padding: 1rem 1.25rem; font-size: 0.95rem; + min-height: 56px; } .form-control, .form-select { - padding: 0.75rem 0.875rem; - font-size: 16px; + padding: 0.875rem 1rem; + min-height: 56px; } .navbar-brand { @@ -508,16 +635,32 @@ } .modal-dialog { - margin: 0.25rem; - max-width: calc(100% - 0.5rem); + margin: 0.5rem; + max-width: calc(100% - 1rem); } .modal-header, .modal-body, .modal-footer { - padding: 0.875rem; + padding: 1rem; } + + /* Enhanced Small Mobile Typography */ + h1 { font-size: 1.75rem; } + h2 { font-size: 1.5rem; } + h3 { font-size: 1.25rem; } + h4 { font-size: 1.125rem; } + h5 { font-size: 1rem; } + h6 { font-size: 0.9rem; } + + /* Enhanced Small Mobile Spacing */ + .mb-4 { margin-bottom: 1.25rem !important; } + .mb-3 { margin-bottom: 1rem !important; } + .mb-2 { margin-bottom: 0.75rem !important; } + + .py-4 { padding-top: 1.25rem !important; padding-bottom: 1.25rem !important; } + .py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } } -/* Landscape mobile devices */ +/* Enhanced Landscape Mobile Devices */ @media (max-width: 768px) and (orientation: landscape) { .navbar-collapse { max-height: 70vh; @@ -528,9 +671,18 @@ margin: 1rem; max-width: calc(100% - 2rem); } + + .card-body { + padding: 1rem; + } + + .btn { + min-height: 48px; + padding: 0.875rem 1.5rem; + } } -/* High-DPI mobile devices */ +/* Enhanced High-DPI Mobile Devices */ @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { .btn, .form-control, .form-select { border-width: 0.5px; @@ -539,61 +691,203 @@ .table td { border-bottom-width: 0.5px; } + + .card { + border-width: 0.5px; + } } -/* Mobile-specific print styles */ +/* Enhanced Mobile Print Styles */ @media print and (max-width: 768px) { - .navbar, .btn, .modal { + .navbar, .btn, .modal, .dropdown { display: none !important; } .card { border: 1px solid #000 !important; box-shadow: none !important; + margin-bottom: 1rem !important; } .table td { border: 1px solid #000 !important; } + + .container { + padding: 0 !important; + max-width: 100% !important; + } } -/* Analytics Dashboard Mobile Styles */ +/* Enhanced Analytics Dashboard Mobile Styles */ @media (max-width: 768px) { .chart-container { min-height: 250px !important; + margin-bottom: var(--mobile-card-spacing); } .analytics-card { - margin-bottom: 1rem; + margin-bottom: var(--mobile-card-spacing); } .analytics-summary-card { - padding: 0.75rem; + padding: 1rem; + text-align: center; } .analytics-summary-card h6 { font-size: 1.1rem; - margin-bottom: 0.25rem; + margin-bottom: 0.5rem; + color: #1e293b; } .analytics-summary-card small { - font-size: 0.75rem; + font-size: 0.8rem; + color: #64748b; } .analytics-summary-card i { - font-size: 1.25rem !important; - margin-bottom: 0.5rem; + font-size: 1.5rem !important; + margin-bottom: 0.75rem; + color: #3b82f6; + } + + .analytics-chart-wrapper { + background: white; + border-radius: var(--mobile-border-radius); + padding: 1rem; + box-shadow: var(--mobile-shadow); + margin-bottom: var(--mobile-card-spacing); } } -/* Chart.js Mobile Optimizations */ +/* Enhanced Chart.js Mobile Optimizations */ @media (max-width: 768px) { .chartjs-tooltip { font-size: 0.875rem !important; - padding: 0.5rem !important; + padding: 0.75rem !important; + border-radius: var(--mobile-border-radius); + box-shadow: var(--mobile-shadow-hover); } .chartjs-legend { font-size: 0.875rem !important; + margin-top: 1rem; + } + + .chartjs-legend-item { + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + } +} + +/* Enhanced Mobile Loading States */ +@media (max-width: 768px) { + .loading-skeleton { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + border-radius: var(--mobile-border-radius); + height: 20px; + margin-bottom: 0.75rem; + } + + @keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + .loading-card { + min-height: 200px; + background: #f8fafc; + border-radius: var(--mobile-border-radius); + display: flex; + align-items: center; + justify-content: center; + color: #64748b; + font-size: 0.9rem; + } +} + +/* Enhanced Mobile Accessibility */ +@media (max-width: 768px) { + /* Focus indicators for better accessibility */ + .btn:focus, + .form-control:focus, + .form-select:focus, + .nav-link:focus { + outline: 2px solid #3b82f6; + outline-offset: 2px; + } + + /* High contrast mode support */ + @media (prefers-contrast: high) { + .card { + border: 2px solid #000; + } + + .btn { + border: 2px solid #000; + } + + .table td { + border-bottom: 2px solid #000; + } + } + + /* Reduced motion support */ + @media (prefers-reduced-motion: reduce) { + .card, + .btn, + .nav-link, + .table tr { + transition: none; + } + + .mobile-fade-in, + .mobile-slide-up { + animation: none; + } + } +} + +/* Enhanced Mobile Dark Mode Support */ +@media (max-width: 768px) { + @media (prefers-color-scheme: dark) { + :root { + --mobile-bg-primary: #1e293b; + --mobile-bg-secondary: #334155; + --mobile-text-primary: #f1f5f9; + --mobile-text-secondary: #cbd5e1; + --mobile-border-color: #475569; + } + + .card { + background: var(--mobile-bg-secondary); + border-color: var(--mobile-border-color); + color: var(--mobile-text-primary); + } + + .navbar { + background: var(--mobile-bg-primary) !important; + } + + .navbar-collapse { + background: var(--mobile-bg-primary); + border-color: var(--mobile-border-color); + } + + .table td { + background: var(--mobile-bg-secondary); + color: var(--mobile-text-secondary); + } + + .form-control, .form-select { + background: var(--mobile-bg-secondary); + border-color: var(--mobile-border-color); + color: var(--mobile-text-primary); + } } } diff --git a/app/static/mobile.js b/app/static/mobile.js index 7204b8c..5412f99 100644 --- a/app/static/mobile.js +++ b/app/static/mobile.js @@ -1,502 +1,606 @@ -// Mobile-specific JavaScript for TimeTracker +/* Enhanced Mobile JavaScript for TimeTracker */ -class MobileEnhancer { +// Mobile-specific variables and utilities +const MobileUtils = { + // Touch target sizes + TOUCH_TARGET_SIZE: 52, + MOBILE_BREAKPOINT: 768, + SMALL_MOBILE_BREAKPOINT: 480, + + // Check if device is mobile + isMobile() { + return window.innerWidth <= this.MOBILE_BREAKPOINT; + }, + + // Check if device is small mobile + isSmallMobile() { + return window.innerWidth <= this.SMALL_MOBILE_BREAKPOINT; + }, + + // Check if device supports touch + isTouchDevice() { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; + }, + + // Check if device is iOS + isIOS() { + return /iPad|iPhone|iPod/.test(navigator.userAgent); + }, + + // Check if device is Android + isAndroid() { + return /Android/.test(navigator.userAgent); + } +}; + +// Enhanced mobile navigation handling +class MobileNavigation { constructor() { - this.isMobile = window.innerWidth <= 768; - this.touchStartX = 0; - this.touchStartY = 0; - this.touchEndX = 0; - this.touchEndY = 0; + this.navbarToggler = document.querySelector('.navbar-toggler'); + this.navbarCollapse = document.querySelector('.navbar-collapse'); + this.navLinks = document.querySelectorAll('.navbar-nav .nav-link'); + this.dropdownItems = document.querySelectorAll('.dropdown-item'); + this.init(); } - + init() { - this.detectMobile(); - this.enhanceTouchTargets(); - this.enhanceTables(); - this.enhanceNavigation(); - this.enhanceModals(); - this.enhanceForms(); - this.addTouchGestures(); - this.handleViewportChanges(); - this.addMobileSpecificFeatures(); - } - - detectMobile() { - if (this.isMobile) { - document.body.classList.add('mobile-view'); - this.addMobileMetaTags(); + if (this.navbarToggler && this.navbarCollapse) { + this.setupEventListeners(); + this.setupTouchHandling(); } } - - addMobileMetaTags() { - // Add mobile-specific meta tags if not already present - if (!document.querySelector('meta[name="mobile-web-app-capable"]')) { - const meta = document.createElement('meta'); - meta.name = 'mobile-web-app-capable'; - meta.content = 'yes'; - document.head.appendChild(meta); - } - - if (!document.querySelector('meta[name="apple-mobile-web-app-capable"]')) { - const meta = document.createElement('meta'); - meta.name = 'apple-mobile-web-app-capable'; - meta.content = 'yes'; - document.head.appendChild(meta); - } - } - - enhanceTouchTargets() { - // Improve touch targets for mobile - const touchElements = document.querySelectorAll('.btn, .form-control, .form-select, .nav-link, .dropdown-item'); - touchElements.forEach(element => { - element.classList.add('touch-target'); + + setupEventListeners() { + // Close mobile menu when clicking outside + document.addEventListener('click', (event) => { + const isClickInsideNavbar = this.navbarToggler.contains(event.target) || + this.navbarCollapse.contains(event.target); - // Add touch feedback - element.addEventListener('touchstart', this.handleTouchStart.bind(this)); - element.addEventListener('touchend', this.handleTouchEnd.bind(this)); + if (!isClickInsideNavbar && this.navbarCollapse.classList.contains('show')) { + this.closeMenu(); + } }); - } - - // Add missing touch event handlers - handleTouchStart(event) { - const element = event.currentTarget; - element.style.transform = 'scale(0.98)'; - element.style.transition = 'transform 0.1s ease'; - // Store touch coordinates for gesture detection - this.touchStartX = event.touches[0].clientX; - this.touchStartY = event.touches[0].clientY; - } - - handleTouchEnd(event) { - const element = event.currentTarget; - element.style.transform = 'scale(1)'; - - // Store end coordinates for gesture detection - this.touchEndX = event.changedTouches[0].clientX; - this.touchEndY = event.changedTouches[0].clientY; - } - - enhanceTables() { - if (!this.isMobile) return; - - const tables = document.querySelectorAll('.table'); - tables.forEach(table => { - const rows = table.querySelectorAll('tbody tr'); - rows.forEach(row => { - const cells = row.querySelectorAll('td'); - cells.forEach((cell, index) => { - // Add data-label attributes for mobile table display - const header = table.querySelector(`thead th:nth-child(${index + 1})`); - if (header) { - cell.setAttribute('data-label', header.textContent.trim()); - } - }); + // Close mobile menu when clicking on nav links + this.navLinks.forEach(link => { + link.addEventListener('click', () => { + if (MobileUtils.isMobile()) { + this.closeMenu(); + } }); }); + + // Close mobile menu when clicking on dropdown items + this.dropdownItems.forEach(item => { + item.addEventListener('click', () => { + if (MobileUtils.isMobile()) { + this.closeMenu(); + } + }); + }); + + // Handle escape key + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && this.navbarCollapse.classList.contains('show')) { + this.closeMenu(); + } + }); } - - enhanceNavigation() { - if (!this.isMobile) return; - - const navbarToggler = document.querySelector('.navbar-toggler'); - const navbarCollapse = document.querySelector('.navbar-collapse'); - - if (navbarToggler && navbarCollapse) { - // Close mobile menu when clicking outside - document.addEventListener('click', (event) => { - const isClickInsideNavbar = navbarToggler.contains(event.target) || navbarCollapse.contains(event.target); + + setupTouchHandling() { + if (MobileUtils.isTouchDevice()) { + // Add touch feedback to nav items + this.navLinks.forEach(link => { + link.addEventListener('touchstart', () => { + link.style.transform = 'scale(0.95)'; + }); - if (!isClickInsideNavbar && navbarCollapse.classList.contains('show')) { - const bsCollapse = new bootstrap.Collapse(navbarCollapse); - bsCollapse.hide(); - } - }); - - // Close mobile menu when clicking on a nav link - const navLinks = navbarCollapse.querySelectorAll('.nav-link'); - navLinks.forEach(link => { - link.addEventListener('click', () => { - if (window.innerWidth <= 991.98) { - const bsCollapse = new bootstrap.Collapse(navbarCollapse); - bsCollapse.hide(); - } + link.addEventListener('touchend', () => { + link.style.transform = 'scale(1)'; }); }); - - // Add swipe to close functionality - this.addSwipeToClose(navbarCollapse); } } + + closeMenu() { + if (this.navbarCollapse.classList.contains('show')) { + const bsCollapse = new bootstrap.Collapse(this.navbarCollapse); + bsCollapse.hide(); + } + } +} - enhanceModals() { - if (!this.isMobile) return; - - // Enhance modal behavior on mobile - const modals = document.querySelectorAll('.modal'); - modals.forEach(modal => { - // Add swipe to close functionality - this.addSwipeToClose(modal); - - // Improve modal positioning - modal.addEventListener('shown.bs.modal', () => { - this.centerModal(modal); +// Enhanced mobile form handling +class MobileForms { + constructor() { + this.forms = document.querySelectorAll('form'); + this.inputs = document.querySelectorAll('.form-control, .form-select'); + + this.init(); + } + + init() { + this.setupFormHandling(); + this.setupInputHandling(); + this.setupMobileOptimizations(); + } + + setupFormHandling() { + this.forms.forEach(form => { + form.addEventListener('submit', (e) => { + this.handleFormSubmit(e, form); }); }); } - - enhanceForms() { - if (!this.isMobile) return; - - // Enhance form inputs for mobile - const forms = document.querySelectorAll('form'); - forms.forEach(form => { - form.classList.add('mobile-form'); - - // Add mobile-specific form validation - this.addMobileFormValidation(form); - - // Improve form submission on mobile - this.enhanceFormSubmission(form); - }); - } - - addTouchGestures() { - if (!this.isMobile) return; - - // Add swipe gestures for navigation - this.addSwipeNavigation(); - - // Add pull-to-refresh functionality - this.addPullToRefresh(); - - // Add touch feedback - this.addTouchFeedback(); - } - - addSwipeToClose(element) { - let startX = 0; - let startY = 0; - let currentX = 0; - let currentY = 0; - - element.addEventListener('touchstart', (e) => { - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - }); - - element.addEventListener('touchmove', (e) => { - currentX = e.touches[0].clientX; - currentY = e.touches[0].clientY; - - const diffX = startX - currentX; - const diffY = startY - currentY; - - // Horizontal swipe to close - if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) { - if (element.classList.contains('navbar-collapse')) { - const bsCollapse = new bootstrap.Collapse(element); - bsCollapse.hide(); - } else if (element.classList.contains('modal')) { - const modal = bootstrap.Modal.getInstance(element); - if (modal) modal.hide(); - } + + setupInputHandling() { + this.inputs.forEach(input => { + // Prevent zoom on iOS + if (MobileUtils.isIOS()) { + input.style.fontSize = '16px'; } - }); - } - - addSwipeNavigation() { - let startX = 0; - let startY = 0; - - document.addEventListener('touchstart', (e) => { - startX = e.touches[0].clientX; - startY = e.touches[0].clientY; - }); - - document.addEventListener('touchend', (e) => { - const endX = e.changedTouches[0].clientX; - const endY = e.changedTouches[0].clientY; - const diffX = startX - endX; - const diffY = startY - endY; - - // Swipe left/right for navigation (if on specific pages) - if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 100) { - this.handleSwipeNavigation(diffX > 0 ? 'left' : 'right'); - } - }); - } - - addPullToRefresh() { - let startY = 0; - let currentY = 0; - let pullDistance = 0; - let isPulling = false; - - document.addEventListener('touchstart', (e) => { - if (window.scrollY === 0) { - startY = e.touches[0].clientY; - isPulling = true; - } - }); - - document.addEventListener('touchmove', (e) => { - if (!isPulling) return; - - currentY = e.touches[0].clientY; - pullDistance = currentY - startY; - - if (pullDistance > 0 && pullDistance < 100) { - this.showPullToRefreshIndicator(pullDistance); - } - }); - - document.addEventListener('touchend', () => { - if (isPulling && pullDistance > 80) { - this.refreshPage(); - } - this.hidePullToRefreshIndicator(); - isPulling = false; - }); - } - - addTouchFeedback() { - const touchElements = document.querySelectorAll('.btn, .card, .nav-link'); - - touchElements.forEach(element => { - element.addEventListener('touchstart', () => { - element.style.transform = 'scale(0.98)'; - element.style.transition = 'transform 0.1s ease'; + // Add focus handling + input.addEventListener('focus', () => { + this.handleInputFocus(input); }); - element.addEventListener('touchend', () => { - element.style.transform = 'scale(1)'; + input.addEventListener('blur', () => { + this.handleInputBlur(input); }); + + // Add touch handling + if (MobileUtils.isTouchDevice()) { + input.addEventListener('touchstart', () => { + this.handleInputTouch(input); + }); + } }); } - - handleSwipeNavigation(direction) { - // Handle swipe navigation based on current page - const currentPath = window.location.pathname; - - if (direction === 'left') { - // Swipe left - go forward - if (window.history.length > 1) { - window.history.forward(); - } - } else { - // Swipe right - go back - if (window.history.length > 1) { - window.history.back(); - } + + setupMobileOptimizations() { + if (MobileUtils.isMobile()) { + // Add mobile-specific classes + this.inputs.forEach(input => { + input.classList.add('touch-target'); + }); + + // Improve form layout on mobile + const formGroups = document.querySelectorAll('.form-group, .mb-3'); + formGroups.forEach(group => { + group.classList.add('mobile-form-group'); + }); } } - - showPullToRefreshIndicator(distance) { - // Create or update pull-to-refresh indicator - let indicator = document.getElementById('pull-to-refresh-indicator'); - if (!indicator) { - indicator = document.createElement('div'); - indicator.id = 'pull-to-refresh-indicator'; - indicator.innerHTML = ` -
- - Pull to refresh -
- `; - indicator.style.cssText = ` - position: fixed; - top: 0; - left: 0; - right: 0; - background: white; - z-index: 9999; - transform: translateY(-100%); - transition: transform 0.3s ease; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); - `; - document.body.appendChild(indicator); - } - - const translateY = Math.min(distance * 0.5, 100); - indicator.style.transform = `translateY(${translateY - 100}px)`; - } - - hidePullToRefreshIndicator() { - const indicator = document.getElementById('pull-to-refresh-indicator'); - if (indicator) { - indicator.style.transform = 'translateY(-100%)'; + + handleFormSubmit(event, form) { + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) { + // Show loading state + submitBtn.innerHTML = '
Processing...'; + submitBtn.disabled = true; + + // Re-enable after a delay (fallback) setTimeout(() => { - if (indicator.parentNode) { - indicator.parentNode.removeChild(indicator); - } + submitBtn.disabled = false; + submitBtn.innerHTML = submitBtn.getAttribute('data-original-text') || 'Submit'; + }, 10000); + } + } + + handleInputFocus(input) { + input.classList.add('focused'); + + // Scroll to input on mobile + if (MobileUtils.isMobile()) { + setTimeout(() => { + input.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); }, 300); } } - - refreshPage() { - // Show loading indicator - this.showLoadingIndicator(); - - // Refresh the page + + handleInputBlur(input) { + input.classList.remove('focused'); + } + + handleInputTouch(input) { + // Add touch feedback + input.style.transform = 'scale(0.98)'; setTimeout(() => { - window.location.reload(); - }, 500); + input.style.transform = 'scale(1)'; + }, 150); } +} - showLoadingIndicator() { - const loading = document.createElement('div'); - loading.id = 'mobile-loading-indicator'; - loading.innerHTML = ` -
-
- Loading... -
-
Refreshing...
-
- `; - loading.style.cssText = ` - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: white; - border-radius: 12px; - box-shadow: 0 4px 20px rgba(0,0,0,0.15); - z-index: 10000; - `; - document.body.appendChild(loading); +// Enhanced mobile table handling +class MobileTables { + constructor() { + this.tables = document.querySelectorAll('.table-responsive'); + + this.init(); } - - centerModal(modal) { - // Center modal content on mobile - const modalContent = modal.querySelector('.modal-content'); - if (modalContent) { - modalContent.style.margin = 'auto'; - modalContent.style.maxHeight = '90vh'; - modalContent.style.overflow = 'auto'; + + init() { + this.setupMobileTables(); + this.setupTouchHandling(); + } + + setupMobileTables() { + if (MobileUtils.isMobile()) { + this.tables.forEach(table => { + this.convertToMobileLayout(table); + }); } } - - addMobileFormValidation(form) { - // Add mobile-specific form validation - const inputs = form.querySelectorAll('input, select, textarea'); + + convertToMobileLayout(table) { + const tbody = table.querySelector('tbody'); + if (!tbody) return; - inputs.forEach(input => { - input.addEventListener('invalid', (e) => { - e.preventDefault(); - this.showMobileValidationError(input); - }); - - input.addEventListener('input', () => { - this.hideMobileValidationError(input); + const rows = tbody.querySelectorAll('tr'); + rows.forEach(row => { + const cells = row.querySelectorAll('td'); + cells.forEach((cell, index) => { + this.addMobileLabels(cell, index); }); }); } - - showMobileValidationError(input) { - // Show mobile-friendly validation error - const errorDiv = document.createElement('div'); - errorDiv.className = 'mobile-validation-error'; - errorDiv.textContent = input.validationMessage; - errorDiv.style.cssText = ` - color: #dc2626; - font-size: 0.875rem; - margin-top: 0.25rem; - padding: 0.5rem; - background: #fef2f2; - border-radius: 6px; - border: 1px solid #fecaca; - `; - - input.parentNode.appendChild(errorDiv); - input.style.borderColor = '#dc2626'; - } - - hideMobileValidationError(input) { - const errorDiv = input.parentNode.querySelector('.mobile-validation-error'); - if (errorDiv) { - errorDiv.remove(); + + addMobileLabels(cell, index) { + const labels = ['Project', 'Duration', 'Date', 'Notes', 'Actions']; + if (labels[index]) { + cell.setAttribute('data-label', labels[index]); } - input.style.borderColor = '#e2e8f0'; } + + setupTouchHandling() { + if (MobileUtils.isTouchDevice()) { + const tableRows = document.querySelectorAll('tbody tr'); + tableRows.forEach(row => { + row.addEventListener('touchstart', () => { + row.style.transform = 'scale(0.98)'; + }); + + row.addEventListener('touchend', () => { + row.style.transform = 'scale(1)'; + }); + }); + } + } +} - enhanceFormSubmission(form) { - // Enhance form submission for mobile - form.addEventListener('submit', (e) => { - const submitBtn = form.querySelector('button[type="submit"]'); - if (submitBtn) { - submitBtn.innerHTML = '
Submitting...'; - submitBtn.disabled = true; +// Enhanced mobile card handling +class MobileCards { + constructor() { + this.cards = document.querySelectorAll('.card'); + + this.init(); + } + + init() { + this.setupCardHandling(); + this.setupMobileOptimizations(); + } + + setupCardHandling() { + this.cards.forEach(card => { + // Add mobile-specific classes + if (MobileUtils.isMobile()) { + card.classList.add('mobile-card'); + } + + // Add hover effects + if (!MobileUtils.isTouchDevice()) { + card.addEventListener('mouseenter', () => { + this.handleCardHover(card, true); + }); + + card.addEventListener('mouseleave', () => { + this.handleCardHover(card, false); + }); + } + + // Add touch handling + if (MobileUtils.isTouchDevice()) { + card.addEventListener('touchstart', () => { + this.handleCardTouch(card, true); + }); + + card.addEventListener('touchend', () => { + this.handleCardTouch(card, false); + }); } }); } - - handleViewportChanges() { - window.addEventListener('resize', () => { - const wasMobile = this.isMobile; - this.isMobile = window.innerWidth <= 768; + + setupMobileOptimizations() { + if (MobileUtils.isMobile()) { + // Improve card spacing + this.cards.forEach(card => { + card.style.marginBottom = '1rem'; + }); - if (wasMobile !== this.isMobile) { - if (this.isMobile) { - document.body.classList.add('mobile-view'); - this.enhanceTouchTargets(); - this.enhanceTables(); + // Add mobile-specific animations + this.cards.forEach((card, index) => { + card.style.animationDelay = `${index * 0.1}s`; + card.classList.add('mobile-fade-in'); + }); + } + } + + handleCardHover(card, isHovering) { + if (isHovering) { + card.style.transform = 'translateY(-4px)'; + card.style.boxShadow = '0 8px 25px rgba(0, 0, 0, 0.15)'; + } else { + card.style.transform = 'translateY(0)'; + card.style.boxShadow = ''; + } + } + + handleCardTouch(card, isTouching) { + if (isTouching) { + card.style.transform = 'scale(0.98)'; + } else { + card.style.transform = 'scale(1)'; + } + } +} + +// Enhanced mobile button handling +class MobileButtons { + constructor() { + this.buttons = document.querySelectorAll('.btn'); + + this.init(); + } + + init() { + this.setupButtonHandling(); + this.setupMobileOptimizations(); + } + + setupButtonHandling() { + this.buttons.forEach(button => { + // Add touch target class + button.classList.add('touch-target'); + + // Add touch handling + if (MobileUtils.isTouchDevice()) { + button.addEventListener('touchstart', () => { + this.handleButtonTouch(button, true); + }); + + button.addEventListener('touchend', () => { + this.handleButtonTouch(button, false); + }); + } + + // Add loading state handling + if (button.type === 'submit') { + button.addEventListener('click', () => { + this.handleButtonClick(button); + }); + } + }); + } + + setupMobileOptimizations() { + if (MobileUtils.isMobile()) { + this.buttons.forEach(button => { + // Make buttons full width on mobile + button.style.width = '100%'; + button.style.marginBottom = '0.75rem'; + + // Improve button sizing + if (button.classList.contains('btn-sm')) { + button.style.minHeight = '44px'; } else { - document.body.classList.remove('mobile-view'); + button.style.minHeight = '52px'; } - } - }); + }); + } } - - addMobileSpecificFeatures() { - if (!this.isMobile) return; - - // Add mobile-specific features - this.addMobileKeyboardHandling(); - this.addMobileScrollOptimization(); - this.addMobilePerformanceOptimizations(); + + handleButtonTouch(button, isTouching) { + if (isTouching) { + button.style.transform = 'scale(0.95)'; + } else { + button.style.transform = 'scale(1)'; + } } - - addMobileKeyboardHandling() { - // Handle mobile keyboard events - const inputs = document.querySelectorAll('input, textarea'); + + handleButtonClick(button) { + // Store original text + const originalText = button.innerHTML; + button.setAttribute('data-original-text', originalText); - inputs.forEach(input => { - input.addEventListener('focus', () => { - // Scroll to input when focused on mobile - setTimeout(() => { - input.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 300); + // Show loading state + button.innerHTML = '
Processing...'; + button.disabled = true; + + // Re-enable after a delay (fallback) + setTimeout(() => { + button.disabled = false; + button.innerHTML = originalText; + }, 10000); + } +} + +// Enhanced mobile modal handling +class MobileModals { + constructor() { + this.modals = document.querySelectorAll('.modal'); + + this.init(); + } + + init() { + this.setupModalHandling(); + this.setupMobileOptimizations(); + } + + setupModalHandling() { + this.modals.forEach(modal => { + // Handle modal close on backdrop click + modal.addEventListener('click', (event) => { + if (event.target === modal) { + this.closeModal(modal); + } + }); + + // Handle escape key + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('show')) { + this.closeModal(modal); + } }); }); } - - addMobileScrollOptimization() { - // Optimize scrolling on mobile - let ticking = false; - - const updateScroll = () => { - // Add scroll-based animations or effects - ticking = false; - }; - - const requestTick = () => { - if (!ticking) { - requestAnimationFrame(updateScroll); - ticking = true; - } - }; - - document.addEventListener('scroll', requestTick, { passive: true }); + + setupMobileOptimizations() { + if (MobileUtils.isMobile()) { + this.modals.forEach(modal => { + const dialog = modal.querySelector('.modal-dialog'); + if (dialog) { + // Improve mobile modal sizing + dialog.style.margin = '0.75rem'; + dialog.style.maxWidth = 'calc(100% - 1.5rem)'; + } + + // Improve mobile modal content + const content = modal.querySelector('.modal-content'); + if (content) { + content.style.borderRadius = '12px'; + } + }); + } } + + closeModal(modal) { + const modalInstance = bootstrap.Modal.getInstance(modal); + if (modalInstance) { + modalInstance.hide(); + } + } +} - addMobilePerformanceOptimizations() { - // Add mobile-specific performance optimizations +// Enhanced mobile viewport handling +class MobileViewport { + constructor() { + this.init(); + } + + init() { + this.setupViewportHandling(); + this.setupOrientationHandling(); + this.setupResizeHandling(); + } + + setupViewportHandling() { + // Set viewport meta tag for mobile + if (MobileUtils.isMobile()) { + this.setViewportMeta(); + } + // Handle initial viewport + this.handleViewportChange(); + } + + setupOrientationHandling() { + window.addEventListener('orientationchange', () => { + setTimeout(() => { + this.handleViewportChange(); + }, 100); + }); + } + + setupResizeHandling() { + let resizeTimeout; + window.addEventListener('resize', () => { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + this.handleViewportChange(); + }, 250); + }); + } + + setViewportMeta() { + let viewport = document.querySelector('meta[name="viewport"]'); + if (!viewport) { + viewport = document.createElement('meta'); + viewport.name = 'viewport'; + document.head.appendChild(viewport); + } + + if (MobileUtils.isIOS()) { + viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; + } else { + viewport.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'; + } + } + + handleViewportChange() { + const isMobile = MobileUtils.isMobile(); + const isSmallMobile = MobileUtils.isSmallMobile(); + + // Update body classes + document.body.classList.toggle('mobile-view', isMobile); + document.body.classList.toggle('small-mobile-view', isSmallMobile); + + // Update card classes + const cards = document.querySelectorAll('.card'); + cards.forEach(card => { + card.classList.toggle('mobile-card', isMobile); + }); + + // Update button classes + const buttons = document.querySelectorAll('.btn'); + buttons.forEach(button => { + button.classList.toggle('mobile-btn', isMobile); + }); + + // Update form classes + const inputs = document.querySelectorAll('.form-control, .form-select'); + inputs.forEach(input => { + input.classList.toggle('mobile-input', isMobile); + }); + } +} + +// Enhanced mobile performance optimization +class MobilePerformance { + constructor() { + this.init(); + } + + init() { + this.setupPerformanceOptimizations(); + this.setupLazyLoading(); + } + + setupPerformanceOptimizations() { + if (MobileUtils.isMobile()) { + // Reduce animations on mobile + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + document.body.classList.add('reduced-motion'); + } + + // Optimize images for mobile + this.optimizeImages(); + + // Optimize fonts for mobile + this.optimizeFonts(); + } + } + + setupLazyLoading() { // Lazy load images + const images = document.querySelectorAll('img[data-src]'); if ('IntersectionObserver' in window) { const imageObserver = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { @@ -509,23 +613,389 @@ class MobileEnhancer { }); }); - document.querySelectorAll('img[data-src]').forEach(img => { - imageObserver.observe(img); - }); + images.forEach(img => imageObserver.observe(img)); } - - // Optimize animations for mobile - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); - if (prefersReducedMotion.matches) { - document.body.style.setProperty('--transition', 'none'); + } + + optimizeImages() { + const images = document.querySelectorAll('img'); + images.forEach(img => { + // Add loading="lazy" for mobile + if (MobileUtils.isMobile()) { + img.loading = 'lazy'; + } + + // Optimize image rendering + img.style.imageRendering = 'optimizeQuality'; + }); + } + + optimizeFonts() { + // Preload critical fonts + if (MobileUtils.isMobile()) { + const fontLink = document.createElement('link'); + fontLink.rel = 'preload'; + fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'; + fontLink.as = 'style'; + document.head.appendChild(fontLink); } } } -// Initialize mobile enhancements when DOM is loaded -document.addEventListener('DOMContentLoaded', () => { - new MobileEnhancer(); +// Enhanced mobile accessibility +class MobileAccessibility { + constructor() { + this.init(); + } + + init() { + this.setupAccessibilityFeatures(); + this.setupKeyboardNavigation(); + } + + setupAccessibilityFeatures() { + // Add ARIA labels for mobile + if (MobileUtils.isMobile()) { + this.addMobileAriaLabels(); + } + + // Improve focus management + this.setupFocusManagement(); + } + + setupKeyboardNavigation() { + // Handle keyboard navigation + document.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + this.handleTabNavigation(event); + } + }); + } + + addMobileAriaLabels() { + // Add labels to interactive elements + const buttons = document.querySelectorAll('.btn'); + buttons.forEach(button => { + if (!button.getAttribute('aria-label')) { + const text = button.textContent.trim(); + if (text) { + button.setAttribute('aria-label', text); + } + } + }); + + // Add labels to form inputs + const inputs = document.querySelectorAll('.form-control, .form-select'); + inputs.forEach(input => { + const label = input.previousElementSibling; + if (label && label.tagName === 'LABEL') { + input.setAttribute('aria-labelledby', label.id || 'label-' + Math.random()); + } + }); + } + + setupFocusManagement() { + // Trap focus in modals + const modals = document.querySelectorAll('.modal'); + modals.forEach(modal => { + modal.addEventListener('keydown', (event) => { + if (event.key === 'Tab') { + this.trapFocusInModal(event, modal); + } + }); + }); + } + + handleTabNavigation(event) { + // Handle tab navigation for mobile + if (MobileUtils.isMobile()) { + const focusableElements = document.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + } + + trapFocusInModal(event, modal) { + const focusableElements = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } +} + +// Enhanced mobile gesture handling +class MobileGestures { + constructor() { + this.init(); + } + + init() { + if (MobileUtils.isTouchDevice()) { + this.setupGestureHandling(); + } + } + + setupGestureHandling() { + // Swipe gestures for navigation + this.setupSwipeGestures(); + + // Pinch to zoom prevention + this.preventPinchZoom(); + + // Double tap handling + this.setupDoubleTapHandling(); + } + + setupSwipeGestures() { + let startX = 0; + let startY = 0; + let endX = 0; + let endY = 0; + + document.addEventListener('touchstart', (event) => { + startX = event.touches[0].clientX; + startY = event.touches[0].clientY; + }); + + document.addEventListener('touchend', (event) => { + endX = event.changedTouches[0].clientX; + endY = event.changedTouches[0].clientY; + + this.handleSwipe(startX, startY, endX, endY); + }); + } + + handleSwipe(startX, startY, endX, endY) { + const diffX = startX - endX; + const diffY = startY - endY; + + // Minimum swipe distance + const minSwipeDistance = 50; + + if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > minSwipeDistance) { + // Horizontal swipe + if (diffX > 0) { + // Swipe left + this.handleSwipeLeft(); + } else { + // Swipe right + this.handleSwipeRight(); + } + } + } + + handleSwipeLeft() { + // Handle swipe left gesture + console.log('Swipe left detected'); + } + + handleSwipeRight() { + // Handle swipe right gesture + console.log('Swipe right detected'); + } + + preventPinchZoom() { + document.addEventListener('gesturestart', (event) => { + event.preventDefault(); + }); + + document.addEventListener('gesturechange', (event) => { + event.preventDefault(); + }); + + document.addEventListener('gestureend', (event) => { + event.preventDefault(); + }); + } + + setupDoubleTapHandling() { + let lastTap = 0; + + document.addEventListener('touchend', (event) => { + const currentTime = new Date().getTime(); + const tapLength = currentTime - lastTap; + + if (tapLength < 500 && tapLength > 0) { + // Double tap detected + this.handleDoubleTap(event); + } + + lastTap = currentTime; + }); + } + + handleDoubleTap(event) { + // Handle double tap gesture + console.log('Double tap detected'); + } +} + +// Enhanced mobile error handling +class MobileErrorHandling { + constructor() { + this.init(); + } + + init() { + this.setupErrorHandling(); + this.setupOfflineHandling(); + } + + setupErrorHandling() { + // Handle JavaScript errors + window.addEventListener('error', (event) => { + this.handleError(event.error); + }); + + // Handle unhandled promise rejections + window.addEventListener('unhandledrejection', (event) => { + this.handleError(event.reason); + }); + } + + setupOfflineHandling() { + // Handle offline/online events + window.addEventListener('offline', () => { + this.handleOffline(); + }); + + window.addEventListener('online', () => { + this.handleOnline(); + }); + } + + handleError(error) { + console.error('Mobile error:', error); + + // Show user-friendly error message + if (MobileUtils.isMobile()) { + this.showMobileError(error); + } + } + + handleOffline() { + console.log('Device went offline'); + + // Show offline indicator + this.showOfflineIndicator(); + } + + handleOnline() { + console.log('Device came online'); + + // Hide offline indicator + this.hideOfflineIndicator(); + } + + showMobileError(error) { + // Create mobile-friendly error message + const errorDiv = document.createElement('div'); + errorDiv.className = 'alert alert-danger mobile-error'; + errorDiv.innerHTML = ` + + Something went wrong
+ Please try again or contact support if the problem persists. + `; + + // Insert at top of page + const container = document.querySelector('.container'); + if (container) { + container.insertBefore(errorDiv, container.firstChild); + + // Auto-remove after 10 seconds + setTimeout(() => { + errorDiv.remove(); + }, 10000); + } + } + + showOfflineIndicator() { + // Create offline indicator + const offlineDiv = document.createElement('div'); + offlineDiv.className = 'alert alert-warning mobile-offline'; + offlineDiv.innerHTML = ` + + You're offline
+ Some features may not work properly. + `; + + offlineDiv.id = 'offline-indicator'; + + // Insert at top of page + const container = document.querySelector('.container'); + if (container && !document.getElementById('offline-indicator')) { + container.insertBefore(offlineDiv, container.firstChild); + } + } + + hideOfflineIndicator() { + const offlineDiv = document.getElementById('offline-indicator'); + if (offlineDiv) { + offlineDiv.remove(); + } + } +} + +// Initialize all mobile enhancements when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + // Prevent double initialization + if (window.mobileEnhancementsInitialized) { + console.log('Mobile enhancements already initialized, skipping...'); + return; + } + + // Mark as initialized + window.mobileEnhancementsInitialized = true; + + // Initialize all mobile classes + new MobileNavigation(); + new MobileForms(); + new MobileTables(); + new MobileCards(); + new MobileButtons(); + new MobileModals(); + new MobileViewport(); + new MobilePerformance(); + new MobileAccessibility(); + new MobileGestures(); + new MobileErrorHandling(); + + // Add mobile-specific body class + if (MobileUtils.isMobile()) { + document.body.classList.add('mobile-view'); + } + + // Log mobile initialization + console.log('Mobile enhancements initialized successfully'); + console.log('Device info:', { + isMobile: MobileUtils.isMobile(), + isSmallMobile: MobileUtils.isSmallMobile(), + isTouchDevice: MobileUtils.isTouchDevice(), + isIOS: MobileUtils.isIOS(), + isAndroid: MobileUtils.isAndroid() + }); }); // Export for use in other scripts -window.MobileEnhancer = MobileEnhancer; +window.MobileUtils = MobileUtils; diff --git a/app/static/uploads/logos/.gitkeep b/app/static/uploads/logos/.gitkeep new file mode 100644 index 0000000..8f5b058 --- /dev/null +++ b/app/static/uploads/logos/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the uploads directory is preserved in git +# Company logos will be stored in this directory diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 4f439a8..a475be9 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -8,7 +8,11 @@
- DryTrix Logo + {% if settings and settings.has_logo() %} + Company Logo + {% else %} + DryTrix Logo + {% endif %}

Welcome to TimeTracker

Powered by DryTrix

diff --git a/app/templates/base.html b/app/templates/base.html index b044e0b..6678102 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -10,7 +10,11 @@ {% block title %}{{ app_name }}{% endblock %} + {% if settings and settings.has_logo() %} + + {% else %} + {% endif %} @@ -41,9 +45,13 @@ --bg-gradient: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); --card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --card-shadow-hover: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - --border-radius: 8px; - --border-radius-sm: 6px; + --border-radius: 12px; + --border-radius-sm: 8px; --transition: all 0.2s ease-in-out; + --section-spacing: 2rem; + --card-spacing: 1.5rem; + --mobile-section-spacing: 1.5rem; + --mobile-card-spacing: 1rem; } * { @@ -72,149 +80,26 @@ main { flex: 1 0 auto; display: block; - padding-bottom: 2rem; + padding-bottom: var(--section-spacing); } - /* Mobile-First Navigation */ - .navbar { - background: white !important; - backdrop-filter: blur(10px); - box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); - border-bottom: 1px solid var(--border-color); - padding: 0.75rem 0; - z-index: 1030; - position: relative; - min-height: var(--mobile-nav-height); + /* Enhanced Container Layout */ + .container { + max-width: 1400px; + padding-left: 1.5rem; + padding-right: 1.5rem; } - .navbar-brand { - font-weight: 700; - font-size: 1.4rem; - color: var(--primary-color) !important; - text-decoration: none; - transition: var(--transition); - display: flex; - align-items: center; + /* Improved Section Spacing */ + .section-spacing { + margin-bottom: var(--section-spacing); } - .navbar-brand img { - transition: var(--transition); + .section-spacing:last-child { + margin-bottom: 0; } - .navbar-brand:hover img { - transform: scale(1.1); - } - - .navbar-brand:hover { - color: var(--primary-dark) !important; - } - - .navbar-nav .nav-link { - color: var(--text-secondary) !important; - font-weight: 500; - padding: 0.75rem 1rem; - border-radius: var(--border-radius-sm); - transition: var(--transition); - position: relative; - margin: 0 0.25rem; - min-height: var(--mobile-touch-target); - display: flex; - align-items: center; - } - - .navbar-nav .nav-link:hover { - color: var(--primary-color) !important; - background: var(--light-color); - } - - .navbar-nav .nav-link.active { - background: var(--primary-color); - color: white !important; - } - - .dropdown-toggle { - z-index: 1051; - } - - .dropdown-menu { - border: 1px solid var(--border-color); - box-shadow: var(--card-shadow); - border-radius: var(--border-radius-sm); - padding: 0.5rem 0; - margin-top: 0.5rem; - z-index: 1050; - position: absolute; - } - - .dropdown-item { - padding: 0.75rem 1.5rem; - transition: var(--transition); - color: var(--text-secondary); - min-height: var(--mobile-touch-target); - display: flex; - align-items: center; - } - - .dropdown-item:hover { - background: var(--light-color); - color: var(--primary-color); - } - - /* Mobile Navigation Toggle */ - .navbar-toggler { - border: none; - padding: 0.5rem; - min-height: var(--mobile-touch-target); - min-width: var(--mobile-touch-target); - } - - .navbar-toggler:focus { - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - } - - /* Mobile Navigation Menu */ - @media (max-width: 991.98px) { - .navbar-collapse { - background: white; - border-top: 1px solid var(--border-color); - margin-top: 0.5rem; - padding: 1rem 0; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - } - - .navbar-nav .nav-link { - padding: 1rem 1.5rem; - margin: 0.25rem 0; - border-radius: var(--border-radius-sm); - font-size: 1.1rem; - } - - .navbar-nav .nav-link i { - width: 24px; - text-align: center; - margin-right: 0.75rem; - } - - .dropdown-menu { - position: static !important; - float: none; - width: 100%; - margin: 0; - border: none; - box-shadow: none; - background: var(--light-color); - border-radius: var(--border-radius-sm); - margin-top: 0.5rem; - } - - .dropdown-item { - padding: 0.75rem 2rem; - border-radius: var(--border-radius-sm); - margin: 0.25rem 0.5rem; - } - } - - /* Card Styling */ + /* Enhanced Card Layout */ .card { border: 1px solid var(--border-color); box-shadow: var(--card-shadow); @@ -222,10 +107,16 @@ transition: var(--transition); background: white; overflow: hidden; + margin-bottom: var(--card-spacing); + } + + .card:last-child { + margin-bottom: 0; } .card:hover { box-shadow: var(--card-shadow-hover); + transform: translateY(-2px); } .card a { @@ -261,26 +152,26 @@ .card-header { background: white; border-bottom: 1px solid var(--border-color); - padding: 1.25rem 1.5rem; + padding: 1.5rem 1.75rem; font-weight: 600; color: var(--text-primary); - font-size: 1rem; + font-size: 1.1rem; } .card-body { - padding: 1.5rem; + padding: 1.75rem; } - /* Button Styling - Mobile Optimized */ + /* Enhanced Button Layout */ .btn { border-radius: var(--border-radius-sm); font-weight: 500; - padding: 0.75rem 1.5rem; + padding: 0.875rem 1.75rem; transition: var(--transition); border: none; position: relative; font-size: 0.95rem; - min-height: var(--mobile-touch-target); + min-height: 48px; display: inline-flex; align-items: center; justify-content: center; @@ -288,6 +179,7 @@ cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; + gap: 0.5rem; } .btn:active { @@ -301,8 +193,8 @@ .btn-primary:hover { background: var(--primary-dark); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.3); } .btn-success { @@ -312,8 +204,8 @@ .btn-success:hover { background: #047857; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(5, 150, 105, 0.3); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(5, 150, 105, 0.3); } .btn-danger { @@ -323,12 +215,12 @@ .btn-danger:hover { background: #b91c1c; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(220, 38, 38, 0.3); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(220, 38, 38, 0.3); } .btn-outline-primary { - border: 1px solid var(--primary-color); + border: 2px solid var(--primary-color); color: var(--primary-color); background: transparent; } @@ -336,11 +228,11 @@ .btn-outline-primary:hover { background: var(--primary-color); color: white; - transform: translateY(-1px); + transform: translateY(-2px); } .btn-outline-secondary { - border: 1px solid var(--border-color); + border: 2px solid var(--border-color); color: var(--text-secondary); background: transparent; } @@ -351,56 +243,31 @@ color: var(--text-primary); } - /* Mobile Button Sizes */ - @media (max-width: 768px) { - .btn { - padding: 1rem 1.5rem; - font-size: 1rem; - min-height: 48px; - } - - .btn-sm { - padding: 0.75rem 1.25rem; - font-size: 0.9rem; - min-height: 40px; - } + /* Enhanced Form Layout */ + .form-control, .form-select { + border: 2px solid var(--border-color); + border-radius: var(--border-radius-sm); + padding: 1rem 1.25rem; + font-size: 1rem; + transition: var(--transition); + background: white; + min-height: 52px; } - /* Timer Display */ - .timer-display { - font-family: 'Inter', monospace; - font-size: 1.75rem; - font-weight: 700; - color: var(--primary-color); - letter-spacing: 1px; + .form-control:focus, .form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); + outline: none; } - /* Stats Cards */ - .stats-card { - background: var(--bg-gradient); - color: white; - position: relative; - overflow: hidden; - } - - .stats-card .card-body { - position: relative; - z-index: 1; - } - - .stats-card i { - font-size: 2rem; - opacity: 0.9; + .form-label { + font-weight: 600; + color: var(--text-primary); margin-bottom: 0.75rem; + font-size: 0.95rem; } - .stats-card h4 { - font-size: 2rem; - font-weight: 700; - margin-bottom: 0.5rem; - } - - /* Table Styling - Mobile Responsive */ + /* Enhanced Table Layout */ .table { border-radius: var(--border-radius-sm); overflow: hidden; @@ -412,15 +279,15 @@ border: none; font-weight: 600; color: var(--text-primary); - padding: 1rem; - text-transform: uppercase; - font-size: 0.8rem; - letter-spacing: 0.5px; - border-bottom: 1px solid var(--border-color); + padding: 1.25rem 1rem; + text-transform: none; + font-size: 0.9rem; + letter-spacing: 0.3px; + border-bottom: 2px solid var(--border-color); } .table td { - padding: 1rem; + padding: 1.25rem 1rem; border-bottom: 1px solid var(--border-color); vertical-align: middle; color: var(--text-secondary); @@ -432,121 +299,387 @@ .table tbody tr:hover { background: var(--light-color); + transform: scale(1.01); } - /* Mobile Table Responsiveness */ + /* Enhanced Navigation Layout */ + .navbar { + background: white !important; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + border-bottom: 1px solid var(--border-color); + padding: 1rem 0; + z-index: 1030; + position: relative; + min-height: 70px; + } + + .navbar-brand { + font-weight: 700; + font-size: 1.5rem; + color: var(--primary-color) !important; + text-decoration: none; + transition: var(--transition); + display: flex; + align-items: center; + gap: 0.75rem; + } + + .navbar-brand img { + transition: var(--transition); + } + + .navbar-brand:hover img { + transform: scale(1.1); + } + + .navbar-brand:hover { + color: var(--primary-dark) !important; + } + + .navbar-nav .nav-link { + color: var(--text-secondary) !important; + font-weight: 500; + padding: 0.875rem 1.25rem; + border-radius: var(--border-radius-sm); + transition: var(--transition); + position: relative; + margin: 0 0.25rem; + min-height: 48px; + display: flex; + align-items: center; + gap: 0.5rem; + } + + .navbar-nav .nav-link:hover { + color: var(--primary-color) !important; + background: var(--light-color); + transform: translateY(-1px); + } + + .navbar-nav .nav-link.active { + background: var(--primary-color); + color: white !important; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + } + + /* Enhanced Mobile Layout */ @media (max-width: 768px) { - .table-responsive { - border: none; + .container { + padding-left: 1rem; + padding-right: 1rem; } - .table { - display: block; + .section-spacing { + margin-bottom: var(--mobile-section-spacing); + } + + .card { + margin-bottom: var(--mobile-card-spacing); + } + + .card-body { + padding: 1.25rem; + } + + .card-header { + padding: 1.25rem 1.25rem; + } + + .btn { + padding: 1rem 1.5rem; + font-size: 1rem; + min-height: 52px; width: 100%; + margin-bottom: 0.75rem; } - .table thead { - display: none; + .btn:last-child { + margin-bottom: 0; } - .table tbody { - display: block; - width: 100%; + .form-control, .form-select { + padding: 1rem 1.25rem; + font-size: 16px; + min-height: 56px; } - .table tr { - display: block; - margin-bottom: 1rem; - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - background: white; - box-shadow: var(--card-shadow); + .table th, .table td { + padding: 1rem 0.75rem; } - .table td { - display: block; - text-align: left; - padding: 0.75rem 1rem; - border: none; - border-bottom: 1px solid var(--border-color); - position: relative; + .navbar { + padding: 0.75rem 0; + min-height: 60px; } - .table td:last-child { - border-bottom: none; + .navbar-brand { + font-size: 1.25rem; } - .table td:before { - content: attr(data-label) ": "; - font-weight: 600; - color: var(--text-primary); - display: block; - margin-bottom: 0.25rem; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; + .navbar-nav .nav-link { + padding: 1rem 1.5rem; + margin: 0.25rem 0; + font-size: 1.1rem; + min-height: 52px; + } + } + + /* Enhanced Small Mobile Layout */ + @media (max-width: 480px) { + .container { + padding-left: 0.75rem; + padding-right: 0.75rem; } - .table td.actions-cell { - text-align: center; + .card-body { padding: 1rem; } - .table td.actions-cell:before { - display: none; - } - } - - /* Badge Styling */ - .badge { - font-size: 0.75rem; - font-weight: 500; - padding: 0.375rem 0.75rem; - border-radius: var(--border-radius-sm); - } - - /* Form Styling - Mobile Optimized */ - .form-control, .form-select { - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - padding: 0.875rem 1rem; - font-size: 1rem; - transition: var(--transition); - background: white; - min-height: var(--mobile-touch-target); - } - - .form-control:focus, .form-select:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - outline: none; - } - - .form-label { - font-weight: 600; - color: var(--text-primary); - margin-bottom: 0.75rem; - font-size: 0.95rem; - } - - /* Mobile Form Improvements */ - @media (max-width: 768px) { - .form-control, .form-select { - font-size: 16px; /* Prevents zoom on iOS */ - padding: 1rem 1.25rem; + .card-header { + padding: 1rem 1rem; } - .form-label { - font-size: 1rem; - margin-bottom: 0.5rem; + .btn { + padding: 1rem 1.25rem; + font-size: 0.95rem; + min-height: 56px; + } + + .form-control, .form-select { + padding: 0.875rem 1rem; + min-height: 56px; + } + + .navbar-brand { + font-size: 1.1rem; } } - /* Alert Styling */ + /* Enhanced Grid Layout */ + .row { + margin-left: -0.75rem; + margin-right: -0.75rem; + } + + .col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 { + padding-left: 0.75rem; + padding-right: 0.75rem; + } + + @media (max-width: 768px) { + .row { + margin-left: -0.5rem; + margin-right: -0.5rem; + } + + .col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 { + padding-left: 0.5rem; + padding-right: 0.5rem; + } + } + + /* Enhanced Typography */ + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary); + font-weight: 600; + line-height: 1.3; + margin-bottom: 1rem; + } + + h1 { font-size: 2.25rem; } + h2 { font-size: 1.875rem; } + h3 { font-size: 1.5rem; } + h4 { font-size: 1.25rem; } + h5 { font-size: 1.125rem; } + h6 { font-size: 1rem; } + + @media (max-width: 768px) { + h1 { font-size: 1.875rem; } + h2 { font-size: 1.625rem; } + h3 { font-size: 1.375rem; } + h4 { font-size: 1.125rem; } + h5 { font-size: 1rem; } + h6 { font-size: 0.95rem; } + } + + /* Enhanced Spacing Utilities */ + .section-gap { + margin-bottom: var(--section-spacing); + } + + .card-gap { + margin-bottom: var(--card-spacing); + } + + @media (max-width: 768px) { + .section-gap { + margin-bottom: var(--mobile-section-spacing); + } + + .card-gap { + margin-bottom: var(--mobile-card-spacing); + } + } + + /* Enhanced Animation Classes */ + .fade-in { + animation: fadeIn 0.4s ease-out; + } + + .slide-up { + animation: slideUp 0.4s ease-out; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } + } + + /* Enhanced Utility Classes */ + .text-gradient { + background: var(--bg-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .glass-effect { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.3); + } + + .hover-lift { + transition: var(--transition); + } + + .hover-lift:hover { + transform: translateY(-4px); + box-shadow: var(--card-shadow-hover); + } + + /* Enhanced Mobile Components */ + .mobile-stack { + display: flex; + flex-direction: column; + } + + .mobile-stack .btn { + margin-bottom: 0.75rem; + } + + .mobile-stack .btn:last-child { + margin-bottom: 0; + } + + .touch-target { + min-height: 48px; + min-width: 48px; + } + + @media (max-width: 768px) { + .touch-target { + min-height: 52px; + min-width: 52px; + } + } + + /* Enhanced Footer Layout */ + .footer { + background: white; + color: var(--text-secondary); + padding: 2.5rem 0; + margin-top: 4rem; + border-top: 1px solid var(--border-color); + } + + .footer a { + color: var(--primary-color); + text-decoration: none; + transition: var(--transition); + } + + .footer a:hover { + color: var(--primary-dark); + } + + /* Enhanced Toast Container */ + .toast-container { + z-index: 9999; + } + + .toast { + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + box-shadow: var(--card-shadow-hover); + } + + /* Enhanced Loading Animation */ + .loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 1s ease-in-out infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* Enhanced Modal Layout */ + .modal-content { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15); + } + + .modal-header { + border-bottom: 1px solid var(--border-color); + padding: 1.75rem; + } + + .modal-body { + padding: 1.75rem; + } + + .modal-footer { + border-top: 1px solid var(--border-color); + padding: 1.75rem; + } + + @media (max-width: 576px) { + .modal-dialog { + margin: 0.75rem; + max-width: calc(100% - 1.5rem); + } + + .modal-header, .modal-body, .modal-footer { + padding: 1.25rem; + } + } + + /* Enhanced Badge Layout */ + .badge { + font-size: 0.8rem; + font-weight: 500; + padding: 0.5rem 0.875rem; + border-radius: var(--border-radius-sm); + } + + /* Enhanced Alert Layout */ .alert { border: 1px solid var(--border-color); border-radius: var(--border-radius-sm); - padding: 1rem 1.25rem; + padding: 1.25rem 1.5rem; font-weight: 500; position: relative; background: white; @@ -576,184 +709,61 @@ color: #92400e; } - /* Modal Styling - Mobile Optimized */ - .modal-content { - border: 1px solid var(--border-color); - border-radius: var(--border-radius); - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); - } - - .modal-header { - border-bottom: 1px solid var(--border-color); - padding: 1.5rem; - } - - .modal-body { - padding: 1.5rem; - } - - .modal-footer { - border-top: 1px solid var(--border-color); - padding: 1.5rem; - } - - /* Mobile Modal Improvements */ - @media (max-width: 576px) { - .modal-dialog { - margin: 0.5rem; - max-width: calc(100% - 1rem); - } - - .modal-content { - border-radius: var(--border-radius); - } - - .modal-header, .modal-body, .modal-footer { - padding: 1rem; - } - } - - /* Footer */ - .footer { - background: white; - color: var(--text-secondary); - padding: 2rem 0; - margin-top: 4rem; - border-top: 1px solid var(--border-color); - } - - .footer a { + /* Enhanced Timer Display */ + .timer-display { + font-family: 'Inter', monospace; + font-size: 2rem; + font-weight: 700; color: var(--primary-color); - text-decoration: none; - transition: var(--transition); + letter-spacing: 2px; } - .footer a:hover { - color: var(--primary-dark); - } - - /* Toast Container */ - .toast-container { - z-index: 9999; - } - - .toast { - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - box-shadow: var(--card-shadow-hover); - } - - /* Loading Animation */ - .loading-spinner { - display: inline-block; - width: 18px; - height: 18px; - border: 2px solid rgba(255, 255, 255, 0.3); - border-radius: 50%; - border-top-color: white; - animation: spin 1s ease-in-out infinite; - } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - /* Enhanced Mobile Responsiveness */ @media (max-width: 768px) { .timer-display { - font-size: 1.5rem; - } - - .stats-card h4 { font-size: 1.75rem; - } - - .card-body { - padding: 1.25rem; - } - - .container { - padding-left: 1rem; - padding-right: 1rem; - } - - .row { - margin-left: -0.5rem; - margin-right: -0.5rem; - } - - .col, .col-1, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-10, .col-11, .col-12 { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - - /* Mobile-specific spacing */ - .mb-4 { margin-bottom: 1.5rem !important; } - .mb-3 { margin-bottom: 1rem !important; } - .mb-2 { margin-bottom: 0.75rem !important; } - - .py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } - .py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } - - /* Mobile navigation improvements */ - .navbar-nav .nav-link { - border-radius: var(--border-radius-sm); - margin: 0.25rem 0; - } - - /* Mobile card improvements */ - .card { - margin-bottom: 1rem; - } - - /* Mobile button group improvements */ - .btn-group { - display: flex; - flex-direction: column; - width: 100%; - } - - .btn-group .btn { - border-radius: var(--border-radius-sm) !important; - margin-bottom: 0.5rem; - } - - .btn-group .btn:last-child { - margin-bottom: 0; + letter-spacing: 1px; } } - /* Small Mobile Devices */ - @media (max-width: 480px) { - .navbar-brand { - font-size: 1.2rem; + /* Enhanced Stats Cards */ + .stats-card { + background: var(--bg-gradient); + color: white; + position: relative; + overflow: hidden; + } + + .stats-card .card-body { + position: relative; + z-index: 1; + } + + .stats-card i { + font-size: 2.5rem; + opacity: 0.9; + margin-bottom: 1rem; + } + + .stats-card h4 { + font-size: 2.25rem; + font-weight: 700; + margin-bottom: 0.75rem; + } + + @media (max-width: 768px) { + .stats-card h4 { + font-size: 2rem; } - .card-body { - padding: 1rem; - } - - .btn { - width: 100%; - margin-bottom: 0.5rem; - } - - .btn:last-child { - margin-bottom: 0; - } - - .d-flex.justify-content-between { - flex-direction: column; - } - - .d-flex.justify-content-between .btn { - margin-bottom: 0.5rem; + .stats-card i { + font-size: 2rem; + margin-bottom: 0.75rem; } } - /* Custom Scrollbar */ + /* Enhanced Custom Scrollbar */ ::-webkit-scrollbar { - width: 6px; + width: 8px; } ::-webkit-scrollbar-track { @@ -762,269 +772,26 @@ ::-webkit-scrollbar-thumb { background: var(--border-color); - border-radius: 3px; + border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } - - /* Animation Classes */ - .fade-in { - animation: fadeIn 0.3s ease-in; - } - - @keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } - } - - /* Utility Classes */ - .text-gradient { - background: var(--bg-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .glass-effect { - background: rgba(255, 255, 255, 0.8); - backdrop-filter: blur(10px); - border: 1px solid rgba(255, 255, 255, 0.2); - } - - /* Typography improvements */ - h1, h2, h3, h4, h5, h6 { - color: var(--text-primary); - font-weight: 600; - line-height: 1.3; - } - - h1 { font-size: 2rem; } - h2 { font-size: 1.75rem; } - h3 { font-size: 1.5rem; } - h4 { font-size: 1.25rem; } - h5 { font-size: 1.125rem; } - h6 { font-size: 1rem; } - - /* Mobile Typography */ - @media (max-width: 768px) { - h1 { font-size: 1.75rem; } - h2 { font-size: 1.5rem; } - h3 { font-size: 1.25rem; } - h4 { font-size: 1.125rem; } - h5 { font-size: 1rem; } - h6 { font-size: 0.95rem; } - } - - .text-muted { - color: var(--text-muted) !important; - } - - .text-secondary { - color: var(--text-secondary) !important; - } - - /* Professional spacing and layout */ - .container-fluid { - max-width: 1400px; - margin: 0 auto; - } - - /* Better table styling */ - .table th { - font-weight: 600; - text-transform: none; - letter-spacing: normal; - font-size: 0.875rem; - } - - /* Improved form styling */ - .form-control:focus, .form-select:focus { - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - outline: none; - } - - /* Better button consistency */ - .btn-sm { - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - } - - /* Professional card headers */ - .card-header h5, .card-header h6 { - margin-bottom: 0; - font-weight: 600; - } - - /* Empty state styling */ - .text-center.py-5 { - background: var(--light-color); - border-radius: var(--border-radius); - } - - .text-center.py-5 i { - color: var(--text-muted); - opacity: 0.6; - } - - /* Better badge styling */ - .badge.bg-primary { - background-color: var(--primary-color) !important; - } - - .badge.bg-success { - background-color: var(--success-color) !important; - } - - .badge.bg-light { - background-color: var(--light-color) !important; - color: var(--text-secondary) !important; - } - - /* Logo image styling */ - .navbar-brand img, - .card-body img[src*="drytrix-logo.svg"] { - image-rendering: -webkit-optimize-contrast; - image-rendering: crisp-edges; - max-width: 100%; - height: auto; - } - - /* Improved spacing */ - .mb-4 { margin-bottom: 1.5rem !important; } - .mb-3 { margin-bottom: 1rem !important; } - .mb-2 { margin-bottom: 0.5rem !important; } - .mb-1 { margin-bottom: 0.25rem !important; } - - .py-4 { padding-top: 1.5rem !important; padding-bottom: 1.5rem !important; } - .py-3 { padding-top: 1rem !important; padding-bottom: 1rem !important; } - .py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; } - - .px-4 { padding-left: 1.5rem !important; padding-right: 1.5rem !important; } - .px-3 { padding-left: 1rem !important; padding-right: 1rem !important; } - - /* Mobile-specific improvements */ - .mobile-stack { - display: flex; - flex-direction: column; - } - - .mobile-stack .btn { - margin-bottom: 0.5rem; - } - - .mobile-stack .btn:last-child { - margin-bottom: 0; - } - - /* Touch-friendly improvements */ - .touch-target { - min-height: var(--mobile-touch-target); - min-width: var(--mobile-touch-target); - } - - /* Mobile navigation improvements */ - .mobile-nav-item { - padding: 1rem 1.5rem; - border-radius: var(--border-radius-sm); - margin: 0.25rem 0; - transition: var(--transition); - } - - .mobile-nav-item:hover { - background: var(--light-color); - } - - .mobile-nav-item.active { - background: var(--primary-color); - color: white; - } - - /* Mobile form improvements */ - .mobile-form-group { - margin-bottom: 1.5rem; - } - - .mobile-form-group .form-label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - } - - .mobile-form-group .form-control, - .mobile-form-group .form-select { - width: 100%; - } - - /* Mobile card improvements */ - .mobile-card { - margin-bottom: 1rem; - border-radius: var(--border-radius); - box-shadow: var(--card-shadow); - } - - .mobile-card .card-body { - padding: 1.25rem; - } - - /* Mobile button improvements */ - .mobile-btn { - width: 100%; - margin-bottom: 0.75rem; - padding: 1rem 1.5rem; - font-size: 1rem; - min-height: 48px; - } - - .mobile-btn:last-child { - margin-bottom: 0; - } - - /* Mobile table improvements */ - .mobile-table-row { - background: white; - border: 1px solid var(--border-color); - border-radius: var(--border-radius-sm); - margin-bottom: 1rem; - padding: 1rem; - box-shadow: var(--card-shadow); - } - - .mobile-table-row .row { - margin: 0; - } - - .mobile-table-row .col { - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-color); - } - - .mobile-table-row .col:last-child { - border-bottom: none; - } - - .mobile-table-row .col:before { - content: attr(data-label) ": "; - font-weight: 600; - color: var(--text-primary); - display: block; - margin-bottom: 0.25rem; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - } {% block extra_css %}{% endblock %} - +