mirror of
https://github.com/michaelstaake/mainty.git
synced 2026-04-16 11:30:13 +00:00
Initial version upload
This commit is contained in:
147
DOCKER.md
Normal file
147
DOCKER.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Docker Setup for Mainty
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the Docker image
|
||||||
|
docker-compose build
|
||||||
|
|
||||||
|
# Start the container
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at: **http://localhost:8080**
|
||||||
|
|
||||||
|
### Stop the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Included
|
||||||
|
|
||||||
|
- **PHP 8.4** with Apache
|
||||||
|
- **SQLite** with PDO extension
|
||||||
|
- **mod_rewrite** enabled for pretty URLs
|
||||||
|
- **Persistent data** volume for the database
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Change Port
|
||||||
|
|
||||||
|
Edit `docker-compose.yml` and modify the ports section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "3000:80" # Access on port 3000 instead
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development Mode
|
||||||
|
|
||||||
|
To sync code changes without rebuilding, uncomment this line in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/www/html/data
|
||||||
|
- .:/var/www/html # Uncomment this line
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart:
|
||||||
|
```bash
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
The SQLite database is stored in the `./data` directory on your host machine, which is mounted to the container. This means:
|
||||||
|
|
||||||
|
- ✅ Your data persists even if you stop/remove the container
|
||||||
|
- ✅ You can backup by copying the `data` folder
|
||||||
|
- ✅ Database survives container rebuilds
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# View container logs
|
||||||
|
docker-compose logs -f mainty
|
||||||
|
|
||||||
|
# Access container shell
|
||||||
|
docker-compose exec mainty bash
|
||||||
|
|
||||||
|
# Stop and remove everything
|
||||||
|
docker-compose down -v
|
||||||
|
|
||||||
|
# Check container status
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Permission Issues
|
||||||
|
|
||||||
|
If you get permission errors with the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod -R 755 data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Already in Use
|
||||||
|
|
||||||
|
If port 8080 is already in use, change it in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ports:
|
||||||
|
- "8081:80" # Use a different port
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Everything
|
||||||
|
|
||||||
|
To start fresh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down -v
|
||||||
|
rm -rf data/mainty.db
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then access http://localhost:8080 to run setup again.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production:
|
||||||
|
|
||||||
|
1. Set `DEBUG` to `false` in `config.php`
|
||||||
|
2. Use environment variables for sensitive data
|
||||||
|
3. Consider using a reverse proxy (nginx) in front
|
||||||
|
4. Enable HTTPS
|
||||||
|
|
||||||
|
Example production `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mainty:
|
||||||
|
build: .
|
||||||
|
container_name: mainty-app
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/www/html/data
|
||||||
|
environment:
|
||||||
|
- APACHE_DOCUMENT_ROOT=/var/www/html
|
||||||
|
restart: always
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
```
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM php:8.4-apache
|
||||||
|
|
||||||
|
# Enable Apache mod_rewrite
|
||||||
|
RUN a2enmod rewrite
|
||||||
|
|
||||||
|
# Install SQLite extensions
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
sqlite3 \
|
||||||
|
libsqlite3-dev \
|
||||||
|
&& docker-php-ext-install pdo_sqlite \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . /var/www/html/
|
||||||
|
|
||||||
|
# Create data directory and set permissions
|
||||||
|
RUN mkdir -p /var/www/html/data && \
|
||||||
|
chown -R www-data:www-data /var/www/html/data && \
|
||||||
|
chmod -R 755 /var/www/html/data
|
||||||
|
|
||||||
|
# Configure Apache to allow .htaccess
|
||||||
|
RUN sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start Apache
|
||||||
|
CMD ["apache2-foreground"]
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Mainty
|
||||||
|
|
||||||
|
A simple PHP web app for tracking vehicle maintenance records. Free, simple, open source, and self-hosted. Runs on any Apache/PHP web server, or use Docker. Uses SQLite for easy backup, with built-in Export via JSON or HTML so you can import that data into something else or print records for your mechanic or the next owner of your vehicle.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Apache web server
|
||||||
|
- PHP 8 or higher
|
||||||
|
- SQLite extension
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Option 1: Traditional Web Server
|
||||||
|
|
||||||
|
1. Upload the entire folder to your web server
|
||||||
|
2. Rename `example.htaccess` to `.htaccess`
|
||||||
|
3. If the app is not in the root directory, edit `.htaccess` and set the `RewriteBase`:
|
||||||
|
```apache
|
||||||
|
RewriteBase /subfolder/
|
||||||
|
```
|
||||||
|
4. Navigate to the app URL in your browser
|
||||||
|
5. If everything is configured correctly, you'll see the setup page
|
||||||
|
6. Set your password to initialize the database
|
||||||
|
|
||||||
|
### Option 2: Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open http://localhost:8080
|
||||||
|
|
||||||
|
## First Time Setup
|
||||||
|
|
||||||
|
When you first access the app, you'll be prompted to:
|
||||||
|
1. Create a password
|
||||||
|
2. Initialize the database
|
||||||
|
|
||||||
|
That's it! You're ready to start tracking your vehicle maintenance.
|
||||||
35
config.php
Normal file
35
config.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// Debug configuration
|
||||||
|
define('DEBUG', false);
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
ini_set('display_errors', 1);
|
||||||
|
ini_set('display_startup_errors', 1);
|
||||||
|
error_reporting(E_ALL);
|
||||||
|
} else {
|
||||||
|
ini_set('display_errors', 0);
|
||||||
|
error_reporting(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
define('DB_PATH', BASE_PATH . '/data/mainty.db');
|
||||||
|
define('DB_DIR', BASE_PATH . '/data');
|
||||||
|
|
||||||
|
// Application configuration
|
||||||
|
define('APP_NAME', 'Mainty');
|
||||||
|
define('ITEMS_PER_PAGE', 20);
|
||||||
|
|
||||||
|
// Base URL configuration - auto-detect the base path
|
||||||
|
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
define('BASE_URL', $scriptName === '/' ? '' : $scriptName);
|
||||||
|
|
||||||
|
// Helper function for URLs
|
||||||
|
function url($path) {
|
||||||
|
return BASE_URL . $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
if (!file_exists(DB_DIR)) {
|
||||||
|
mkdir(DB_DIR, 0755, true);
|
||||||
|
}
|
||||||
41
controllers/AuthController.php
Normal file
41
controllers/AuthController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class AuthController extends Controller {
|
||||||
|
public function login(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if (empty($password)) {
|
||||||
|
$_SESSION['error'] = 'Password is required';
|
||||||
|
$this->redirect('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
|
||||||
|
if ($user->verifyPassword($password)) {
|
||||||
|
$_SESSION['user_id'] = $user->getId();
|
||||||
|
$this->redirect('/home');
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Invalid password';
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void {
|
||||||
|
session_destroy();
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
119
controllers/MaintenanceController.php
Normal file
119
controllers/MaintenanceController.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class MaintenanceController extends Controller {
|
||||||
|
private Maintenance $maintenanceModel;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->maintenanceModel = new Maintenance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$vehicleId = (int)($_POST['vehicle_id'] ?? 0);
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$date = trim($_POST['date'] ?? '');
|
||||||
|
$mileage = str_replace(',', '', trim($_POST['mileage'] ?? ''));
|
||||||
|
|
||||||
|
if (empty($name) || empty($date) || empty($mileage)) {
|
||||||
|
$_SESSION['error'] = 'Name, date, and mileage are required';
|
||||||
|
$this->redirect('/vehicles/' . $vehicleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'vehicle_id' => $vehicleId,
|
||||||
|
'name' => $name,
|
||||||
|
'date' => $date,
|
||||||
|
'mileage' => (int)$mileage,
|
||||||
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
|
'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null,
|
||||||
|
'parts_list' => trim($_POST['parts_list'] ?? ''),
|
||||||
|
'performed_by' => trim($_POST['performed_by'] ?? '')
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->maintenanceModel->create($data);
|
||||||
|
$_SESSION['success'] = 'Maintenance item added successfully';
|
||||||
|
$this->redirect('/vehicles/' . $vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$item = $this->maintenanceModel->getById((int)$id);
|
||||||
|
|
||||||
|
if (!$item) {
|
||||||
|
$_SESSION['error'] = 'Maintenance item not found';
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$date = trim($_POST['date'] ?? '');
|
||||||
|
$mileage = str_replace(',', '', trim($_POST['mileage'] ?? ''));
|
||||||
|
|
||||||
|
if (empty($name) || empty($date) || empty($mileage)) {
|
||||||
|
$_SESSION['error'] = 'Name, date, and mileage are required';
|
||||||
|
$this->redirect('/vehicles/' . $item['vehicle_id']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'date' => $date,
|
||||||
|
'mileage' => (int)$mileage,
|
||||||
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
|
'cost' => !empty($_POST['cost']) ? (float)$_POST['cost'] : null,
|
||||||
|
'parts_list' => trim($_POST['parts_list'] ?? ''),
|
||||||
|
'performed_by' => trim($_POST['performed_by'] ?? '')
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->maintenanceModel->update((int)$id, $data)) {
|
||||||
|
$_SESSION['success'] = 'Maintenance item updated successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to update maintenance item';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/vehicles/' . $item['vehicle_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$item = $this->maintenanceModel->getById((int)$id);
|
||||||
|
|
||||||
|
if (!$item) {
|
||||||
|
$_SESSION['error'] = 'Maintenance item not found';
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$vehicleId = $item['vehicle_id'];
|
||||||
|
|
||||||
|
if ($this->maintenanceModel->delete((int)$id)) {
|
||||||
|
$_SESSION['success'] = 'Maintenance item deleted successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to delete maintenance item';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/vehicles/' . $vehicleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$query = $_GET['q'] ?? '';
|
||||||
|
|
||||||
|
if (strlen($query) < 2) {
|
||||||
|
$this->json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = $this->maintenanceModel->searchByName($query);
|
||||||
|
$this->json($results);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
controllers/SettingsController.php
Normal file
99
controllers/SettingsController.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class SettingsController extends Controller {
|
||||||
|
private QuickTask $quickTaskModel;
|
||||||
|
private User $userModel;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->quickTaskModel = new QuickTask();
|
||||||
|
$this->userModel = new User();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$quickTasks = $this->quickTaskModel->getAll();
|
||||||
|
|
||||||
|
$this->view('settings', [
|
||||||
|
'quickTasks' => $quickTasks
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changePassword(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$currentPassword = $_POST['current_password'] ?? '';
|
||||||
|
$newPassword = $_POST['new_password'] ?? '';
|
||||||
|
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
if (empty($currentPassword) || empty($newPassword) || empty($confirmPassword)) {
|
||||||
|
$_SESSION['error'] = 'All password fields are required';
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->userModel->verifyPassword($currentPassword)) {
|
||||||
|
$_SESSION['error'] = 'Current password is incorrect';
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newPassword !== $confirmPassword) {
|
||||||
|
$_SESSION['error'] = 'New passwords do not match';
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($newPassword) < 6) {
|
||||||
|
$_SESSION['error'] = 'New password must be at least 6 characters';
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->userModel->updatePassword($newPassword)) {
|
||||||
|
$_SESSION['success'] = 'Password changed successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to change password';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addQuickTask(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($name)) {
|
||||||
|
$_SESSION['error'] = 'Task name is required';
|
||||||
|
$this->redirect('/settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $this->quickTaskModel->create($name);
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$_SESSION['success'] = 'Quick task added successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Task already exists or could not be added';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteQuickTask(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
if ($this->quickTaskModel->delete((int)$id)) {
|
||||||
|
$_SESSION['success'] = 'Quick task deleted successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to delete quick task';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
controllers/SetupController.php
Normal file
49
controllers/SetupController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class SetupController extends Controller {
|
||||||
|
public function index(): void {
|
||||||
|
// Check if database is already initialized
|
||||||
|
if (Database::isInitialized()) {
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('setup');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setup(): void {
|
||||||
|
if (Database::isInitialized()) {
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$confirmPassword = $_POST['confirm_password'] ?? '';
|
||||||
|
|
||||||
|
if (empty($password)) {
|
||||||
|
$_SESSION['error'] = 'Password is required';
|
||||||
|
$this->redirect('/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== $confirmPassword) {
|
||||||
|
$_SESSION['error'] = 'Passwords do not match';
|
||||||
|
$this->redirect('/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($password) < 6) {
|
||||||
|
$_SESSION['error'] = 'Password must be at least 6 characters';
|
||||||
|
$this->redirect('/setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Database::initialize($password)) {
|
||||||
|
$_SESSION['success'] = 'Setup completed successfully! Please login.';
|
||||||
|
$this->redirect('/login');
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Setup failed. Please try again.';
|
||||||
|
$this->redirect('/setup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
controllers/VehicleController.php
Normal file
150
controllers/VehicleController.php
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class VehicleController extends Controller {
|
||||||
|
private Vehicle $vehicleModel;
|
||||||
|
private Maintenance $maintenanceModel;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->vehicleModel = new Vehicle();
|
||||||
|
$this->maintenanceModel = new Maintenance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$vehicles = $this->vehicleModel->getAll();
|
||||||
|
$viewMode = $_GET['view'] ?? 'grid'; // grid or list
|
||||||
|
|
||||||
|
$this->view('index', [
|
||||||
|
'vehicles' => $vehicles,
|
||||||
|
'viewMode' => $viewMode
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$vehicle = $this->vehicleModel->getById((int)$id);
|
||||||
|
|
||||||
|
if (!$vehicle) {
|
||||||
|
$_SESSION['error'] = 'Vehicle not found';
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id);
|
||||||
|
|
||||||
|
$this->view('vehicle', [
|
||||||
|
'vehicle' => $vehicle,
|
||||||
|
'maintenanceItems' => $maintenanceItems
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function add(): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($name)) {
|
||||||
|
$_SESSION['error'] = 'Vehicle name is required';
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'year' => trim($_POST['year'] ?? ''),
|
||||||
|
'make' => trim($_POST['make'] ?? ''),
|
||||||
|
'model' => trim($_POST['model'] ?? ''),
|
||||||
|
'color' => trim($_POST['color'] ?? ''),
|
||||||
|
'license_plate' => trim($_POST['license_plate'] ?? '')
|
||||||
|
];
|
||||||
|
|
||||||
|
$id = $this->vehicleModel->create($data);
|
||||||
|
$_SESSION['success'] = 'Vehicle added successfully';
|
||||||
|
$this->redirect('/vehicles/' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
|
||||||
|
if (empty($name)) {
|
||||||
|
$_SESSION['error'] = 'Vehicle name is required';
|
||||||
|
$this->redirect('/vehicles/' . $id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'name' => $name,
|
||||||
|
'year' => trim($_POST['year'] ?? ''),
|
||||||
|
'make' => trim($_POST['make'] ?? ''),
|
||||||
|
'model' => trim($_POST['model'] ?? ''),
|
||||||
|
'color' => trim($_POST['color'] ?? ''),
|
||||||
|
'license_plate' => trim($_POST['license_plate'] ?? '')
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->vehicleModel->update((int)$id, $data)) {
|
||||||
|
$_SESSION['success'] = 'Vehicle updated successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to update vehicle';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/vehicles/' . $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $id): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
if ($this->vehicleModel->delete((int)$id)) {
|
||||||
|
$_SESSION['success'] = 'Vehicle deleted successfully';
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Failed to delete vehicle';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/home');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(string $id, string $format): void {
|
||||||
|
$this->requireSetup();
|
||||||
|
$this->requireAuth();
|
||||||
|
|
||||||
|
$vehicle = $this->vehicleModel->getById((int)$id);
|
||||||
|
|
||||||
|
if (!$vehicle) {
|
||||||
|
$_SESSION['error'] = 'Vehicle not found';
|
||||||
|
$this->redirect('/home');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maintenanceItems = $this->maintenanceModel->getByVehicleId((int)$id);
|
||||||
|
|
||||||
|
if ($format === 'json') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.json"');
|
||||||
|
echo json_encode([
|
||||||
|
'vehicle' => $vehicle,
|
||||||
|
'maintenance_items' => $maintenanceItems
|
||||||
|
], JSON_PRETTY_PRINT);
|
||||||
|
exit;
|
||||||
|
} elseif ($format === 'html') {
|
||||||
|
header('Content-Type: text/html');
|
||||||
|
header('Content-Disposition: attachment; filename="vehicle-' . $id . '-export.html"');
|
||||||
|
|
||||||
|
$this->view('export', [
|
||||||
|
'vehicle' => $vehicle,
|
||||||
|
'maintenanceItems' => $maintenanceItems
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = 'Invalid export format';
|
||||||
|
$this->redirect('/vehicles/' . $id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
core/Controller.php
Normal file
41
core/Controller.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Controller {
|
||||||
|
protected function view(string $viewName, array $data = []): void {
|
||||||
|
extract($data);
|
||||||
|
|
||||||
|
$viewPath = BASE_PATH . '/views/' . $viewName . '.php';
|
||||||
|
|
||||||
|
if (!file_exists($viewPath)) {
|
||||||
|
die("View not found: $viewName");
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $viewPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirect(string $path): void {
|
||||||
|
// Add base URL if path doesn't already include it
|
||||||
|
$url = BASE_URL . $path;
|
||||||
|
header('Location: ' . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function json(mixed $data, int $statusCode = 200): void {
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($data);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireAuth(): void {
|
||||||
|
if (!isset($_SESSION['user_id'])) {
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireSetup(): void {
|
||||||
|
if (!Database::isInitialized()) {
|
||||||
|
$this->redirect('/setup');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
core/Database.php
Normal file
130
core/Database.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/config.php';
|
||||||
|
|
||||||
|
class Database {
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
public static function getInstance(): PDO {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
try {
|
||||||
|
self::$instance = new PDO('sqlite:' . DB_PATH);
|
||||||
|
self::$instance->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
self::$instance->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
die('Database connection failed: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isInitialized(): bool {
|
||||||
|
if (!file_exists(DB_PATH)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$db = self::getInstance();
|
||||||
|
$result = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='users'");
|
||||||
|
return $result->fetch() !== false;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function initialize(string $password): bool {
|
||||||
|
try {
|
||||||
|
$db = self::getInstance();
|
||||||
|
|
||||||
|
// Create users table
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create vehicles table
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
year TEXT,
|
||||||
|
make TEXT,
|
||||||
|
model TEXT,
|
||||||
|
color TEXT,
|
||||||
|
license_plate TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create maintenance_items table
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
vehicle_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
date DATE NOT NULL,
|
||||||
|
mileage INTEGER NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
cost REAL,
|
||||||
|
parts_list TEXT,
|
||||||
|
performed_by TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Create quick_tasks table (predefined maintenance items)
|
||||||
|
$db->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS quick_tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
");
|
||||||
|
|
||||||
|
// Insert default password
|
||||||
|
$stmt = $db->prepare("INSERT INTO users (password_hash) VALUES (?)");
|
||||||
|
$stmt->execute([password_hash($password, PASSWORD_DEFAULT)]);
|
||||||
|
|
||||||
|
// Insert default quick tasks
|
||||||
|
$defaultTasks = [
|
||||||
|
'Oil Change - Oil and Filter',
|
||||||
|
'Oil Change - Oil Only',
|
||||||
|
'Tire Rotation',
|
||||||
|
'Air Filter Replacement',
|
||||||
|
'Battery Replacement',
|
||||||
|
'Spark Plug Replacement',
|
||||||
|
'Wiper Blade Replacement - Front',
|
||||||
|
'Wiper Blade Replacement - Rear',
|
||||||
|
'Wiper Blade Replacement - Front and Rear',
|
||||||
|
'Cabin Air Filter Replacement',
|
||||||
|
'Wheel Alignment',
|
||||||
|
'Tire Replacement',
|
||||||
|
'Brake Fluid Change',
|
||||||
|
'Power Steering Fluid Change',
|
||||||
|
'Fuel Filter Replacement',
|
||||||
|
'Serpentine Belt Replacement',
|
||||||
|
'Timing Belt Replacement',
|
||||||
|
'Inspection/Emissions Test',
|
||||||
|
'Registration Renewed',
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $db->prepare("INSERT INTO quick_tasks (name) VALUES (?)");
|
||||||
|
foreach ($defaultTasks as $task) {
|
||||||
|
$stmt->execute([$task]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Database initialization failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
core/Router.php
Normal file
74
core/Router.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Router {
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
public function get(string $path, string $handler): void {
|
||||||
|
$this->addRoute('GET', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $path, string $handler): void {
|
||||||
|
$this->addRoute('POST', $path, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRoute(string $method, string $path, string $handler): void {
|
||||||
|
$this->routes[] = [
|
||||||
|
'method' => $method,
|
||||||
|
'path' => $path,
|
||||||
|
'handler' => $handler
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $uri, string $method): void {
|
||||||
|
// Remove query string
|
||||||
|
$uri = parse_url($uri, PHP_URL_PATH);
|
||||||
|
|
||||||
|
// Remove base directory from URI if running in subdirectory
|
||||||
|
$scriptName = dirname($_SERVER['SCRIPT_NAME']);
|
||||||
|
if ($scriptName !== '/' && strpos($uri, $scriptName) === 0) {
|
||||||
|
$uri = substr($uri, strlen($scriptName));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure URI starts with /
|
||||||
|
if (empty($uri) || $uri[0] !== '/') {
|
||||||
|
$uri = '/' . $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash except for root
|
||||||
|
if ($uri !== '/' && str_ends_with($uri, '/')) {
|
||||||
|
$uri = rtrim($uri, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $method) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pattern = $this->convertToRegex($route['path']);
|
||||||
|
|
||||||
|
if (preg_match($pattern, $uri, $matches)) {
|
||||||
|
array_shift($matches); // Remove full match
|
||||||
|
$this->callHandler($route['handler'], $matches);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 Not Found
|
||||||
|
http_response_code(404);
|
||||||
|
echo '404 - Page Not Found<br>';
|
||||||
|
echo 'Requested URI: ' . htmlspecialchars($uri) . '<br>';
|
||||||
|
echo 'Request Method: ' . htmlspecialchars($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertToRegex(string $path): string {
|
||||||
|
$path = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([a-zA-Z0-9_-]+)', $path);
|
||||||
|
return '#^' . $path . '$#';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callHandler(string $handler, array $params): void {
|
||||||
|
[$controller, $method] = explode('@', $handler);
|
||||||
|
|
||||||
|
$controllerInstance = new $controller();
|
||||||
|
call_user_func_array([$controllerInstance, $method], $params);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
mainty:
|
||||||
|
build: .
|
||||||
|
container_name: mainty-app
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./data:/var/www/html/data
|
||||||
|
# Uncomment below to sync code changes in development
|
||||||
|
# - .:/var/www/html
|
||||||
|
environment:
|
||||||
|
- APACHE_DOCUMENT_ROOT=/var/www/html
|
||||||
|
restart: unless-stopped
|
||||||
14
example.htaccess
Normal file
14
example.htaccess
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
#if this is in a folder, edit this. example.com/mainty/ would be /mainty/
|
||||||
|
RewriteBase /
|
||||||
|
|
||||||
|
# Redirect to HTTPS (optional, uncomment if needed)
|
||||||
|
# RewriteCond %{HTTPS} off
|
||||||
|
# RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# Don't rewrite files or directories
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|
||||||
|
# Rewrite everything else to index.php
|
||||||
|
RewriteRule ^(.*)$ index.php [L,QSA]
|
||||||
71
index.php
Normal file
71
index.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
// Start session
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// Require PHP 8.0 or higher
|
||||||
|
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
|
||||||
|
die('This application requires PHP 8.0 or higher. Current version: ' . PHP_VERSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SQLite is available
|
||||||
|
if (!extension_loaded('pdo_sqlite')) {
|
||||||
|
die('SQLite PDO extension is not available. Please enable it in your PHP configuration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define base path
|
||||||
|
define('BASE_PATH', __DIR__);
|
||||||
|
|
||||||
|
// Autoloader
|
||||||
|
spl_autoload_register(function ($class) {
|
||||||
|
$directories = [
|
||||||
|
BASE_PATH . '/core/',
|
||||||
|
BASE_PATH . '/controllers/',
|
||||||
|
BASE_PATH . '/models/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($directories as $directory) {
|
||||||
|
$file = $directory . $class . '.php';
|
||||||
|
if (file_exists($file)) {
|
||||||
|
require_once $file;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize router
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
// Define routes
|
||||||
|
$router->get('/', 'SetupController@index');
|
||||||
|
$router->get('/setup', 'SetupController@index');
|
||||||
|
$router->post('/setup', 'SetupController@setup');
|
||||||
|
|
||||||
|
$router->get('/login', 'AuthController@login');
|
||||||
|
$router->post('/login', 'AuthController@authenticate');
|
||||||
|
$router->get('/logout', 'AuthController@logout');
|
||||||
|
|
||||||
|
$router->get('/home', 'VehicleController@index');
|
||||||
|
$router->get('/vehicles', 'VehicleController@index');
|
||||||
|
$router->post('/vehicles/add', 'VehicleController@add');
|
||||||
|
$router->get('/vehicles/{id}', 'VehicleController@show');
|
||||||
|
$router->post('/vehicles/{id}/edit', 'VehicleController@edit');
|
||||||
|
$router->post('/vehicles/{id}/delete', 'VehicleController@delete');
|
||||||
|
$router->get('/vehicles/{id}/export/{format}', 'VehicleController@export');
|
||||||
|
|
||||||
|
$router->post('/maintenance/add', 'MaintenanceController@add');
|
||||||
|
$router->post('/maintenance/{id}/edit', 'MaintenanceController@edit');
|
||||||
|
$router->post('/maintenance/{id}/delete', 'MaintenanceController@delete');
|
||||||
|
$router->get('/maintenance/search', 'MaintenanceController@search');
|
||||||
|
|
||||||
|
$router->get('/settings', 'SettingsController@index');
|
||||||
|
$router->post('/settings/password', 'SettingsController@changePassword');
|
||||||
|
$router->post('/settings/quick-tasks/add', 'SettingsController@addQuickTask');
|
||||||
|
$router->post('/settings/quick-tasks/{id}/delete', 'SettingsController@deleteQuickTask');
|
||||||
|
|
||||||
|
// Dispatch the request
|
||||||
|
try {
|
||||||
|
$router->dispatch($_SERVER['REQUEST_URI'], $_SERVER['REQUEST_METHOD']);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo "Error: " . $e->getMessage();
|
||||||
|
}
|
||||||
88
models/Maintenance.php
Normal file
88
models/Maintenance.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Maintenance {
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getByVehicleId(int $vehicleId): array {
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT * FROM maintenance_items
|
||||||
|
WHERE vehicle_id = ?
|
||||||
|
ORDER BY date DESC, id DESC
|
||||||
|
");
|
||||||
|
$stmt->execute([$vehicleId]);
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getById(int $id): ?array {
|
||||||
|
$stmt = $this->db->prepare("SELECT * FROM maintenance_items WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$item = $stmt->fetch();
|
||||||
|
return $item ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): int {
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
INSERT INTO maintenance_items
|
||||||
|
(vehicle_id, name, date, mileage, description, cost, parts_list, performed_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$data['vehicle_id'],
|
||||||
|
$data['name'],
|
||||||
|
$data['date'],
|
||||||
|
$data['mileage'],
|
||||||
|
$data['description'] ?? null,
|
||||||
|
$data['cost'] ?? null,
|
||||||
|
$data['parts_list'] ?? null,
|
||||||
|
$data['performed_by'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int)$this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): bool {
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
UPDATE maintenance_items
|
||||||
|
SET name = ?, date = ?, mileage = ?, description = ?, cost = ?, parts_list = ?, performed_by = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
|
||||||
|
return $stmt->execute([
|
||||||
|
$data['name'],
|
||||||
|
$data['date'],
|
||||||
|
$data['mileage'],
|
||||||
|
$data['description'] ?? null,
|
||||||
|
$data['cost'] ?? null,
|
||||||
|
$data['parts_list'] ?? null,
|
||||||
|
$data['performed_by'] ?? null,
|
||||||
|
$id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool {
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM maintenance_items WHERE id = ?");
|
||||||
|
return $stmt->execute([$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchByName(string $query): array {
|
||||||
|
// Search in both quick_tasks and existing maintenance items
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
SELECT DISTINCT name FROM (
|
||||||
|
SELECT name FROM quick_tasks WHERE name LIKE ?
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT name FROM maintenance_items WHERE name LIKE ?
|
||||||
|
)
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
|
||||||
|
$searchTerm = '%' . $query . '%';
|
||||||
|
$stmt->execute([$searchTerm, $searchTerm]);
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
models/QuickTask.php
Normal file
33
models/QuickTask.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class QuickTask {
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array {
|
||||||
|
$stmt = $this->db->query("SELECT * FROM quick_tasks ORDER BY name ASC");
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(string $name): int {
|
||||||
|
try {
|
||||||
|
$stmt = $this->db->prepare("INSERT INTO quick_tasks (name) VALUES (?)");
|
||||||
|
$stmt->execute([$name]);
|
||||||
|
return (int)$this->db->lastInsertId();
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
// Handle duplicate entry
|
||||||
|
if ($e->getCode() == 23000) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool {
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM quick_tasks WHERE id = ?");
|
||||||
|
return $stmt->execute([$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
models/User.php
Normal file
36
models/User.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class User {
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyPassword(string $password): bool {
|
||||||
|
$stmt = $this->db->query("SELECT password_hash FROM users LIMIT 1");
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return password_verify($password, $user['password_hash']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(string $newPassword): bool {
|
||||||
|
try {
|
||||||
|
$stmt = $this->db->prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP");
|
||||||
|
return $stmt->execute([password_hash($newPassword, PASSWORD_DEFAULT)]);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('Password update failed: ' . $e->getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int {
|
||||||
|
$stmt = $this->db->query("SELECT id FROM users LIMIT 1");
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
return $user ? (int)$user['id'] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
models/Vehicle.php
Normal file
70
models/Vehicle.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
class Vehicle {
|
||||||
|
private PDO $db;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(): array {
|
||||||
|
$stmt = $this->db->query("
|
||||||
|
SELECT v.*,
|
||||||
|
COUNT(m.id) as maintenance_count,
|
||||||
|
MAX(m.date) as last_maintenance_date
|
||||||
|
FROM vehicles v
|
||||||
|
LEFT JOIN maintenance_items m ON v.id = m.vehicle_id
|
||||||
|
GROUP BY v.id
|
||||||
|
ORDER BY v.name ASC
|
||||||
|
");
|
||||||
|
return $stmt->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getById(int $id): ?array {
|
||||||
|
$stmt = $this->db->prepare("SELECT * FROM vehicles WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$vehicle = $stmt->fetch();
|
||||||
|
return $vehicle ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): int {
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
INSERT INTO vehicles (name, year, make, model, color, license_plate)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$data['name'],
|
||||||
|
$data['year'] ?? null,
|
||||||
|
$data['make'] ?? null,
|
||||||
|
$data['model'] ?? null,
|
||||||
|
$data['color'] ?? null,
|
||||||
|
$data['license_plate'] ?? null
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int)$this->db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(int $id, array $data): bool {
|
||||||
|
$stmt = $this->db->prepare("
|
||||||
|
UPDATE vehicles
|
||||||
|
SET name = ?, year = ?, make = ?, model = ?, color = ?, license_plate = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
|
||||||
|
return $stmt->execute([
|
||||||
|
$data['name'],
|
||||||
|
$data['year'] ?? null,
|
||||||
|
$data['make'] ?? null,
|
||||||
|
$data['model'] ?? null,
|
||||||
|
$data['color'] ?? null,
|
||||||
|
$data['license_plate'] ?? null,
|
||||||
|
$id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): bool {
|
||||||
|
$stmt = $this->db->prepare("DELETE FROM vehicles WHERE id = ?");
|
||||||
|
return $stmt->execute([$id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
robots.txt
Normal file
2
robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
114
views/export.php
Normal file
114
views/export.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo htmlspecialchars($vehicle['name']); ?> - Maintenance Export</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-white p-8">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-2"><?php echo APP_NAME; ?></h1>
|
||||||
|
<h2 class="text-xl text-gray-600">Vehicle Maintenance Export</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicle Information -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6 mb-8">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4">Vehicle Information</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Name:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['name']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if ($vehicle['year']): ?>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Year:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['year']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['make']): ?>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Make:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['make']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['model']): ?>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Model:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['model']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['color']): ?>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">Color:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['color']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['license_plate']): ?>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-700">License Plate:</span>
|
||||||
|
<span class="text-gray-600"><?php echo htmlspecialchars($vehicle['license_plate']); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Maintenance History -->
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-800 mb-4">Maintenance History</h3>
|
||||||
|
|
||||||
|
<?php if (empty($maintenanceItems)): ?>
|
||||||
|
<p class="text-gray-500 text-center py-8">No maintenance records available.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<?php foreach ($maintenanceItems as $item): ?>
|
||||||
|
<div class="border border-gray-300 rounded-lg p-4">
|
||||||
|
<div class="flex justify-between items-start mb-2">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-800"><?php echo htmlspecialchars($item['name']); ?></h4>
|
||||||
|
<?php if ($item['cost']): ?>
|
||||||
|
<span class="bg-green-100 text-green-800 px-3 py-1 rounded">
|
||||||
|
$<?php echo number_format($item['cost'], 2); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<div>
|
||||||
|
<strong>Date:</strong> <?php echo date('M d, Y', strtotime($item['date'])); ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Mileage:</strong> <?php echo number_format($item['mileage']); ?> miles
|
||||||
|
</div>
|
||||||
|
<?php if ($item['performed_by']): ?>
|
||||||
|
<div>
|
||||||
|
<strong>Performed By:</strong> <?php echo htmlspecialchars($item['performed_by']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($item['parts_list']): ?>
|
||||||
|
<div>
|
||||||
|
<strong>Parts:</strong> <?php echo htmlspecialchars($item['parts_list']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($item['description']): ?>
|
||||||
|
<div class="mt-2 text-sm text-gray-600">
|
||||||
|
<strong>Description:</strong><br>
|
||||||
|
<?php echo nl2br(htmlspecialchars($item['description'])); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-center text-sm text-gray-500">
|
||||||
|
<p>Exported on <?php echo date('F j, Y \a\t g:i A'); ?></p>
|
||||||
|
<p>Generated by <?php echo APP_NAME; ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
210
views/index.php
Normal file
210
views/index.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo APP_NAME; ?> - Vehicles</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-white shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<a href="<?php echo url('/home'); ?>" class="text-2xl font-bold text-gray-800 hover:text-gray-600 transition"><?php echo APP_NAME; ?></a>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="<?php echo url('/settings'); ?>" class="text-gray-600 hover:text-gray-800">
|
||||||
|
<i class="bi bi-gear-fill text-2xl"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Header with view toggle and add button -->
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700">My Vehicles</h2>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex bg-white rounded-lg shadow-sm">
|
||||||
|
<a href="<?php echo url('/home?view=grid'); ?>"
|
||||||
|
class="px-3 py-2 <?php echo $viewMode === 'grid' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'; ?> rounded-l-lg transition">
|
||||||
|
<i class="bi bi-grid-3x3-gap-fill"></i>
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo url('/home?view=list'); ?>"
|
||||||
|
class="px-3 py-2 <?php echo $viewMode === 'list' ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100'; ?> rounded-r-lg transition">
|
||||||
|
<i class="bi bi-list-ul"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button onclick="showAddVehicleModal()"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center space-x-2 transition">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
<span>Add Vehicle</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Vehicles Display -->
|
||||||
|
<?php if (empty($vehicles)): ?>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-12 text-center">
|
||||||
|
<i class="bi bi-car-front text-6xl text-gray-300 mb-4"></i>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-700 mb-2">No vehicles yet</h3>
|
||||||
|
<p class="text-gray-500 mb-4">Get started by adding your first vehicle</p>
|
||||||
|
<button onclick="showAddVehicleModal()"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||||
|
Add Your First Vehicle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php if ($viewMode === 'grid'): ?>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<?php foreach ($vehicles as $vehicle): ?>
|
||||||
|
<a href="<?php echo url('/vehicles/' . $vehicle['id']); ?>"
|
||||||
|
class="bg-white rounded-lg shadow-sm hover:shadow-md transition p-6 block">
|
||||||
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div class="bg-blue-100 p-3 rounded-lg">
|
||||||
|
<i class="bi bi-car-front-fill text-blue-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500"><?php echo $vehicle['maintenance_count']; ?> records</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-2"><?php echo htmlspecialchars($vehicle['name']); ?></h3>
|
||||||
|
<div class="space-y-1 text-sm text-gray-600">
|
||||||
|
<?php if ($vehicle['year'] || $vehicle['make'] || $vehicle['model']): ?>
|
||||||
|
<p><?php echo implode(' ', array_filter([$vehicle['year'], $vehicle['make'], $vehicle['model']])); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['license_plate']): ?>
|
||||||
|
<p><i class="bi bi-credit-card-2-front"></i> <?php echo htmlspecialchars($vehicle['license_plate']); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['last_maintenance_date']): ?>
|
||||||
|
<p class="text-xs text-gray-500 mt-2">Last service: <?php echo date('M d, Y', strtotime($vehicle['last_maintenance_date'])); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 border-b">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Vehicle</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">License Plate</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Records</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Service</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<?php foreach ($vehicles as $vehicle): ?>
|
||||||
|
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='<?php echo url('/vehicles/' . $vehicle['id']); ?>'">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="bg-blue-100 p-2 rounded">
|
||||||
|
<i class="bi bi-car-front-fill text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<div class="text-sm font-medium text-gray-900"><?php echo htmlspecialchars($vehicle['name']); ?></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
<?php echo implode(' ', array_filter([$vehicle['year'], $vehicle['make'], $vehicle['model']])) ?: '-'; ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
<?php echo htmlspecialchars($vehicle['license_plate']) ?: '-'; ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
<?php echo $vehicle['maintenance_count']; ?>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
|
||||||
|
<?php echo $vehicle['last_maintenance_date'] ? date('M d, Y', strtotime($vehicle['last_maintenance_date'])) : '-'; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add Vehicle Modal -->
|
||||||
|
<div id="addVehicleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Add New Vehicle</h3>
|
||||||
|
<button onclick="hideAddVehicleModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="<?php echo url('/vehicles/add'); ?>">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input type="text" name="name" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Year</label>
|
||||||
|
<input type="text" name="year"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Color</label>
|
||||||
|
<input type="text" name="color"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Make</label>
|
||||||
|
<input type="text" name="make"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||||
|
<input type="text" name="model"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">License Plate</label>
|
||||||
|
<input type="text" name="license_plate"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3 mt-6">
|
||||||
|
<button type="button" onclick="hideAddVehicleModal()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition">
|
||||||
|
Add Vehicle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function showAddVehicleModal() {
|
||||||
|
document.getElementById('addVehicleModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAddVehicleModal() {
|
||||||
|
document.getElementById('addVehicleModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
views/login.php
Normal file
41
views/login.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo APP_NAME; ?> - Login</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-6 text-center"><?php echo APP_NAME; ?></h1>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="POST" action="<?php echo url('/login'); ?>" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter your password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition duration-200">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
129
views/settings.php
Normal file
129
views/settings.php
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo APP_NAME; ?> - Settings</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-white shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<a href="<?php echo url('/home'); ?>" class="text-2xl font-bold text-gray-800 hover:text-gray-600 transition"><?php echo APP_NAME; ?></a>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="<?php echo url('/settings'); ?>" class="text-gray-600 hover:text-gray-800">
|
||||||
|
<i class="bi bi-gear-fill text-2xl"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="<?php echo url('/home'); ?>" class="text-blue-600 hover:text-blue-800">← Back to Vehicles</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-6">Settings</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Quick Tasks Section -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<i class="bi bi-lightning-charge-fill text-yellow-500 mr-2"></i>
|
||||||
|
Quick Tasks
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Predefined maintenance items for quick selection when adding records.</p>
|
||||||
|
|
||||||
|
<!-- Add Quick Task Form -->
|
||||||
|
<form method="POST" action="<?php echo url('/settings/quick-tasks/add'); ?>" class="mb-4">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<input type="text" name="name" required placeholder="New task name"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Quick Tasks List -->
|
||||||
|
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
<?php foreach ($quickTasks as $task): ?>
|
||||||
|
<div class="flex items-center justify-between p-2 hover:bg-gray-50 rounded border border-gray-200">
|
||||||
|
<span class="text-gray-700"><?php echo htmlspecialchars($task['name']); ?></span>
|
||||||
|
<form method="POST" action="<?php echo url('/settings/quick-tasks/' . $task['id'] . '/delete'); ?>"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this quick task?')" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Section -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<i class="bi bi-key-fill text-blue-500 mr-2"></i>
|
||||||
|
Change Password
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">Update your login password.</p>
|
||||||
|
|
||||||
|
<form method="POST" action="<?php echo url('/settings/password'); ?>" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Current Password</label>
|
||||||
|
<input type="password" name="current_password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
||||||
|
<input type="password" name="new_password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="At least 6 characters">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
|
||||||
|
<input type="password" name="confirm_password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition">
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Logout Section -->
|
||||||
|
<div class="mt-6 bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||||
|
<i class="bi bi-box-arrow-right text-red-500 mr-2"></i>
|
||||||
|
Account
|
||||||
|
</h3>
|
||||||
|
<a href="<?php echo url('/logout'); ?>"
|
||||||
|
class="inline-block bg-red-100 hover:bg-red-200 text-red-700 px-6 py-2 rounded-md transition">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
78
views/setup.php
Normal file
78
views/setup.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo APP_NAME; ?> - Setup</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-6 text-center"><?php echo APP_NAME; ?> Setup</h1>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<h2 class="font-semibold text-blue-900 mb-2">System Requirements</h2>
|
||||||
|
<ul class="space-y-1 text-sm">
|
||||||
|
<li class="flex items-center">
|
||||||
|
<i class="bi bi-check-circle-fill text-green-600 mr-2"></i>
|
||||||
|
<span>PHP Version: <?php echo PHP_VERSION; ?>
|
||||||
|
<?php if (version_compare(PHP_VERSION, '8.0.0', '>=')): ?>
|
||||||
|
<span class="text-green-600">(✓ Meets requirement)</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="text-red-600">(✗ Requires 8.0+)</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center">
|
||||||
|
<?php if (extension_loaded('pdo_sqlite')): ?>
|
||||||
|
<i class="bi bi-check-circle-fill text-green-600 mr-2"></i>
|
||||||
|
<span>SQLite: <span class="text-green-600">Available (✓)</span></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="bi bi-x-circle-fill text-red-600 mr-2"></i>
|
||||||
|
<span>SQLite: <span class="text-red-600">Not Available (✗)</span></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$canSetup = version_compare(PHP_VERSION, '8.0.0', '>=') && extension_loaded('pdo_sqlite');
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?php if (!$canSetup): ?>
|
||||||
|
<div class="bg-yellow-100 border border-yellow-400 text-yellow-800 px-4 py-3 rounded mb-4">
|
||||||
|
<p class="font-semibold">System requirements not met!</p>
|
||||||
|
<p class="text-sm mt-1">Please ensure PHP 8.0+ and SQLite extension are available.</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<form method="POST" action="<?php echo url('/setup'); ?>" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Create Password</label>
|
||||||
|
<input type="password" id="password" name="password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter password (min 6 characters)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Confirm password">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-md transition duration-200">
|
||||||
|
Complete Setup
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
425
views/vehicle.php
Normal file
425
views/vehicle.php
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?php echo APP_NAME; ?> - <?php echo htmlspecialchars($vehicle['name']); ?></title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 min-h-screen">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="bg-white shadow-sm">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<a href="<?php echo url('/home'); ?>" class="text-2xl font-bold text-gray-800 hover:text-gray-600 transition"><?php echo APP_NAME; ?></a>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a href="<?php echo url('/settings'); ?>" class="text-gray-600 hover:text-gray-800">
|
||||||
|
<i class="bi bi-gear-fill text-2xl"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="<?php echo url('/home'); ?>" class="text-blue-600 hover:text-blue-800">← Back to Vehicles</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['error']); unset($_SESSION['error']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
|
||||||
|
<?php echo htmlspecialchars($_SESSION['success']); unset($_SESSION['success']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Vehicle Info Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="bg-blue-100 p-4 rounded-lg">
|
||||||
|
<i class="bi bi-car-front-fill text-blue-600 text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-800 mb-2"><?php echo htmlspecialchars($vehicle['name']); ?></h2>
|
||||||
|
<div class="space-y-1 text-gray-600">
|
||||||
|
<?php if ($vehicle['year'] || $vehicle['make'] || $vehicle['model']): ?>
|
||||||
|
<p class="flex items-center">
|
||||||
|
<i class="bi bi-info-circle mr-2"></i>
|
||||||
|
<?php echo implode(' ', array_filter([$vehicle['year'], $vehicle['make'], $vehicle['model']])); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['color']): ?>
|
||||||
|
<p class="flex items-center">
|
||||||
|
<i class="bi bi-palette mr-2"></i>
|
||||||
|
<?php echo htmlspecialchars($vehicle['color']); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($vehicle['license_plate']): ?>
|
||||||
|
<p class="flex items-center">
|
||||||
|
<i class="bi bi-credit-card-2-front mr-2"></i>
|
||||||
|
<?php echo htmlspecialchars($vehicle['license_plate']); ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button onclick="showEditVehicleModal()"
|
||||||
|
class="px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition">
|
||||||
|
<i class="bi bi-pencil"></i> Edit
|
||||||
|
</button>
|
||||||
|
<div class="relative">
|
||||||
|
<button onclick="toggleExportMenu()"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition">
|
||||||
|
<i class="bi bi-download"></i> Export
|
||||||
|
</button>
|
||||||
|
<div id="exportMenu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg z-10">
|
||||||
|
<a href="<?php echo url('/vehicles/' . $vehicle['id'] . '/export/json'); ?>"
|
||||||
|
class="block px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-t-lg">
|
||||||
|
<i class="bi bi-filetype-json"></i> Export as JSON
|
||||||
|
</a>
|
||||||
|
<a href="<?php echo url('/vehicles/' . $vehicle['id'] . '/export/html'); ?>"
|
||||||
|
class="block px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-b-lg">
|
||||||
|
<i class="bi bi-filetype-html"></i> Export as HTML
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="confirmDelete()"
|
||||||
|
class="px-4 py-2 bg-red-100 hover:bg-red-200 text-red-700 rounded-lg transition">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Maintenance Item Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Add Maintenance Record</h3>
|
||||||
|
<form method="POST" action="<?php echo url('/maintenance/add'); ?>">
|
||||||
|
<input type="hidden" name="vehicle_id" value="<?php echo $vehicle['id']; ?>">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Maintenance Name *</label>
|
||||||
|
<input type="text" name="name" id="maintenanceName" required autocomplete="off"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
<div id="suggestions" class="hidden absolute z-10 bg-white border border-gray-300 rounded-md shadow-lg mt-1 max-h-48 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date *</label>
|
||||||
|
<input type="date" name="date" required value="<?php echo date('Y-m-d'); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Mileage *</label>
|
||||||
|
<input type="number" name="mileage" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Cost</label>
|
||||||
|
<input type="number" step="0.01" name="cost"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Performed By</label>
|
||||||
|
<input type="text" name="performed_by"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Parts List</label>
|
||||||
|
<input type="text" name="parts_list"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea name="description" rows="3"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||||
|
<i class="bi bi-plus-lg"></i> Add Record
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Maintenance History -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800 mb-4">Maintenance History (<?php echo count($maintenanceItems); ?>)</h3>
|
||||||
|
|
||||||
|
<?php if (empty($maintenanceItems)): ?>
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="bi bi-tools text-4xl mb-2"></i>
|
||||||
|
<p>No maintenance records yet</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<?php foreach ($maintenanceItems as $item): ?>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4 hover:border-blue-300 transition">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-800"><?php echo htmlspecialchars($item['name']); ?></h4>
|
||||||
|
<?php if ($item['cost']): ?>
|
||||||
|
<span class="bg-green-100 text-green-800 px-2 py-1 rounded text-sm">
|
||||||
|
$<?php echo number_format($item['cost'], 2); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600 mb-2">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-calendar3"></i>
|
||||||
|
<?php echo date('M d, Y', strtotime($item['date'])); ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-speedometer"></i>
|
||||||
|
<?php echo number_format($item['mileage']); ?> miles
|
||||||
|
</div>
|
||||||
|
<?php if ($item['performed_by']): ?>
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-person"></i>
|
||||||
|
<?php echo htmlspecialchars($item['performed_by']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($item['parts_list']): ?>
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-box"></i>
|
||||||
|
<?php echo htmlspecialchars($item['parts_list']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php if ($item['description']): ?>
|
||||||
|
<p class="text-sm text-gray-600 mt-2"><?php echo nl2br(htmlspecialchars($item['description'])); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2 ml-4">
|
||||||
|
<button onclick='showEditMaintenanceModal(<?php echo json_encode($item); ?>)'
|
||||||
|
class="text-blue-600 hover:text-blue-800">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<form method="POST" action="<?php echo url('/maintenance/' . $item['id'] . '/delete'); ?>"
|
||||||
|
onsubmit="return confirm('Are you sure you want to delete this record?')" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Edit Vehicle Modal -->
|
||||||
|
<div id="editVehicleModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Edit Vehicle</h3>
|
||||||
|
<button onclick="hideEditVehicleModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="<?php echo url('/vehicles/' . $vehicle['id'] . '/edit'); ?>">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input type="text" name="name" required value="<?php echo htmlspecialchars($vehicle['name']); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Year</label>
|
||||||
|
<input type="text" name="year" value="<?php echo htmlspecialchars($vehicle['year'] ?? ''); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Color</label>
|
||||||
|
<input type="text" name="color" value="<?php echo htmlspecialchars($vehicle['color'] ?? ''); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Make</label>
|
||||||
|
<input type="text" name="make" value="<?php echo htmlspecialchars($vehicle['make'] ?? ''); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Model</label>
|
||||||
|
<input type="text" name="model" value="<?php echo htmlspecialchars($vehicle['model'] ?? ''); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">License Plate</label>
|
||||||
|
<input type="text" name="license_plate" value="<?php echo htmlspecialchars($vehicle['license_plate'] ?? ''); ?>"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3 mt-6">
|
||||||
|
<button type="button" onclick="hideEditVehicleModal()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Maintenance Modal -->
|
||||||
|
<div id="editMaintenanceModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Edit Maintenance Record</h3>
|
||||||
|
<button onclick="hideEditMaintenanceModal()" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="editMaintenanceForm" method="POST">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Maintenance Name *</label>
|
||||||
|
<input type="text" name="name" id="editName" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date *</label>
|
||||||
|
<input type="date" name="date" id="editDate" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Mileage *</label>
|
||||||
|
<input type="number" name="mileage" id="editMileage" required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Cost</label>
|
||||||
|
<input type="number" step="0.01" name="cost" id="editCost"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Performed By</label>
|
||||||
|
<input type="text" name="performed_by" id="editPerformedBy"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Parts List</label>
|
||||||
|
<input type="text" name="parts_list" id="editPartsList"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||||
|
<textarea name="description" id="editDescription" rows="3"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button type="button" onclick="hideEditMaintenanceModal()"
|
||||||
|
class="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 transition">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Vehicle Form -->
|
||||||
|
<form id="deleteVehicleForm" method="POST" action="<?php echo url('/vehicles/' . $vehicle['id'] . '/delete'); ?>" class="hidden"></form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let debounceTimer;
|
||||||
|
const nameInput = document.getElementById('maintenanceName');
|
||||||
|
const suggestionsDiv = document.getElementById('suggestions');
|
||||||
|
|
||||||
|
nameInput.addEventListener('input', function() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const query = this.value;
|
||||||
|
|
||||||
|
if (query.length < 2) {
|
||||||
|
suggestionsDiv.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
fetch('<?php echo url('/maintenance/search'); ?>?q=' + encodeURIComponent(query))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(items => {
|
||||||
|
if (items.length > 0) {
|
||||||
|
suggestionsDiv.innerHTML = items.map(item =>
|
||||||
|
`<div class="px-3 py-2 hover:bg-gray-100 cursor-pointer" onclick="selectSuggestion('${item.replace(/'/g, "\\'")}')">${item}</div>`
|
||||||
|
).join('');
|
||||||
|
suggestionsDiv.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
suggestionsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectSuggestion(value) {
|
||||||
|
nameInput.value = value;
|
||||||
|
suggestionsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!nameInput.contains(e.target) && !suggestionsDiv.contains(e.target)) {
|
||||||
|
suggestionsDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showEditVehicleModal() {
|
||||||
|
document.getElementById('editVehicleModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEditVehicleModal() {
|
||||||
|
document.getElementById('editVehicleModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEditMaintenanceModal(item) {
|
||||||
|
document.getElementById('editName').value = item.name;
|
||||||
|
document.getElementById('editDate').value = item.date;
|
||||||
|
document.getElementById('editMileage').value = item.mileage;
|
||||||
|
document.getElementById('editCost').value = item.cost || '';
|
||||||
|
document.getElementById('editPerformedBy').value = item.performed_by || '';
|
||||||
|
document.getElementById('editPartsList').value = item.parts_list || '';
|
||||||
|
document.getElementById('editDescription').value = item.description || '';
|
||||||
|
document.getElementById('editMaintenanceForm').action = '<?php echo url('/maintenance/'); ?>' + item.id + '/edit';
|
||||||
|
document.getElementById('editMaintenanceModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideEditMaintenanceModal() {
|
||||||
|
document.getElementById('editMaintenanceModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
if (confirm('Are you sure you want to delete this vehicle and all its maintenance records?')) {
|
||||||
|
document.getElementById('deleteVehicleForm').submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleExportMenu() {
|
||||||
|
document.getElementById('exportMenu').classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const exportMenu = document.getElementById('exportMenu');
|
||||||
|
if (!e.target.closest('#exportMenu') && !e.target.closest('button[onclick="toggleExportMenu()"]')) {
|
||||||
|
exportMenu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user