Add development documentation and enhance SPA navigation with category/item detail views
- Add DEVELOPMENT.md with complete LXC environment setup guide - Implement dynamic category tree with item navigation in sidebar - Add category detail view with items list via AJAX - Add item edit form with delete functionality - Enhance SPA routing to support query parameters for categories/items - Update Bootstrap styling with icons and improved navigation - Include SQLite database in repository for development
This commit is contained in:
203
DEVELOPMENT.md
Normal file
203
DEVELOPMENT.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# LocalWeb Development Environment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the LocalWeb Collections PHP MVC application development environment setup using LXC containers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Host Environment
|
||||||
|
- **Location**: `/home/edwin/Documents/Projects/localweb/`
|
||||||
|
- **User**: `edwin`
|
||||||
|
- **Container Runtime**: Incus/LXC
|
||||||
|
- **Container Name**: `www`
|
||||||
|
|
||||||
|
### LXC Container `www`
|
||||||
|
- **OS**: Linux distribution with Apache2 + PHP
|
||||||
|
- **Web Root**: `/var/www/localhost/` (mounted from host project directory)
|
||||||
|
- **Service**: `apache2.service` (running as systemd service)
|
||||||
|
- **Database**: SQLite (`collections.sqlite`)
|
||||||
|
- **PHP Version**: Compatible with Composer dependencies
|
||||||
|
|
||||||
|
### Network Setup
|
||||||
|
- **LXC Proxy**: Container port 80 → Host port 80
|
||||||
|
- **Access**: `http://localhost` on host serves the webapp
|
||||||
|
- **Internal**: Apache binds to `127.0.0.1:80` inside container
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/edwin/Documents/Projects/localweb/
|
||||||
|
├── src/ # PHP MVC source code
|
||||||
|
│ ├── Controllers/ # Route handlers
|
||||||
|
│ ├── Models/ # Data models
|
||||||
|
│ └── Services/ # Business logic
|
||||||
|
├── templates/ # Twig templates
|
||||||
|
├── public/ # Web root (entry point)
|
||||||
|
├── vendor/ # Composer dependencies
|
||||||
|
├── collections.sqlite # SQLite database
|
||||||
|
└── config.php # Configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 1. Local Development
|
||||||
|
- Edit files in `/home/edwin/Documents/Projects/localweb/`
|
||||||
|
- Changes are immediately reflected in the container via shared mount
|
||||||
|
- Test via `curl localhost` or browser at `http://localhost`
|
||||||
|
|
||||||
|
### 2. Container Management
|
||||||
|
```bash
|
||||||
|
# Execute commands in container
|
||||||
|
incus exec www -- <command>
|
||||||
|
|
||||||
|
# Check Apache status
|
||||||
|
incus exec www -- systemctl status apache2
|
||||||
|
|
||||||
|
# View Apache logs
|
||||||
|
incus exec www -- tail -f /var/log/apache2/error.log
|
||||||
|
|
||||||
|
# Reload Apache
|
||||||
|
incus exec www -- systemctl reload apache2
|
||||||
|
|
||||||
|
# Check PHP version
|
||||||
|
incus exec www -- php -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Database Operations
|
||||||
|
```bash
|
||||||
|
# Access database in container
|
||||||
|
incus exec www -- sqlite3 /var/www/localhost/collections.sqlite
|
||||||
|
|
||||||
|
# Check database permissions
|
||||||
|
incus exec www -- ls -la /var/www/localhost/collections.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Application Details
|
||||||
|
|
||||||
|
### Technology Stack
|
||||||
|
- **PHP**: MVC application with PSR-4 autoloading
|
||||||
|
- **Framework**: Custom MVC with FastRoute routing
|
||||||
|
- **Templating**: Twig templates
|
||||||
|
- **Database**: SQLite with PDO
|
||||||
|
- **Frontend**: Bootstrap 5 + vanilla JavaScript (SPA-like)
|
||||||
|
- **Dependencies**: Managed via Composer
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
- **Collections Management**: CRUD operations for items
|
||||||
|
- **Categories**: Hierarchical category structure
|
||||||
|
- **QR Codes**: Automatic generation for items
|
||||||
|
- **Multi-language**: Dutch/English support
|
||||||
|
- **Search & Filter**: Dynamic content loading
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
- **Web Routes**: Full page loads (`/`, `/categories`, `/parts`)
|
||||||
|
- **API Routes**: AJAX endpoints (`/api/items`, `/api/categories`)
|
||||||
|
- **Entry Point**: `public/index.php`
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
```bash
|
||||||
|
# Install/update dependencies
|
||||||
|
composer install
|
||||||
|
composer update
|
||||||
|
|
||||||
|
# Check for security issues
|
||||||
|
composer audit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Manual testing via curl
|
||||||
|
curl localhost
|
||||||
|
curl -s localhost | grep -i "collections"
|
||||||
|
|
||||||
|
# Test specific endpoints
|
||||||
|
curl localhost/api/items
|
||||||
|
curl localhost/api/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
```bash
|
||||||
|
# Check Apache error logs
|
||||||
|
incus exec www -- tail -f /var/log/apache2/error.log
|
||||||
|
|
||||||
|
# Check access logs
|
||||||
|
incus exec www -- tail -f /var/log/apache2/access.log
|
||||||
|
|
||||||
|
# Verify file permissions
|
||||||
|
incus exec www -- ls -la /var/www/localhost/
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Permissions
|
||||||
|
|
||||||
|
### Container Permissions
|
||||||
|
- **Project files**: `ubuntu:www-data` ownership
|
||||||
|
- **Database**: `ubuntu:ubuntu` ownership
|
||||||
|
- **Apache**: Runs as `www-data` user (group access)
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
- Database permission errors: Check `collections.sqlite` ownership
|
||||||
|
- 403 errors: Verify file permissions in container
|
||||||
|
- 500 errors: Check Apache error logs
|
||||||
|
|
||||||
|
## Environment Variables & Configuration
|
||||||
|
|
||||||
|
### PHP Configuration
|
||||||
|
- Located in container PHP configuration
|
||||||
|
- Error reporting configured for development
|
||||||
|
- SQLite extension enabled
|
||||||
|
|
||||||
|
### Application Config
|
||||||
|
- `config.php`: Database path and constants
|
||||||
|
- `SUPPORTED_LOCALES`: ['en', 'nl'] for i18n
|
||||||
|
- `DB_PATH`: Points to SQLite database file
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
### Container Persistence
|
||||||
|
- Data persists in container filesystem
|
||||||
|
- Database file stored in project directory
|
||||||
|
- No external database dependencies
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
- Git repository for code
|
||||||
|
- SQLite database backup: `collections.sqlite.backup`
|
||||||
|
- Regular snapshots of LXC container recommended
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
1. **Apache not responding**: Check service status in container
|
||||||
|
2. **Database errors**: Verify SQLite file permissions
|
||||||
|
3. **404 errors**: Check `.htaccess` and routing configuration
|
||||||
|
4. **Permission denied**: Ensure `www-data` can read project files
|
||||||
|
|
||||||
|
### Recovery Commands
|
||||||
|
```bash
|
||||||
|
# Restart Apache
|
||||||
|
incus exec www -- systemctl restart apache2
|
||||||
|
|
||||||
|
# Fix permissions (if needed)
|
||||||
|
incus exec www -- chown -R ubuntu:www-data /var/www/localhost/
|
||||||
|
incus exec www -- chmod -R 755 /var/www/localhost/
|
||||||
|
|
||||||
|
# Check container status
|
||||||
|
incus list
|
||||||
|
incus info www
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Container isolation via LXC
|
||||||
|
- No direct database access from host
|
||||||
|
- Apache runs with limited privileges
|
||||||
|
- SQLite database in project directory (not web-exposed)
|
||||||
|
|
||||||
|
## Performance Notes
|
||||||
|
|
||||||
|
- Lightweight SQLite database
|
||||||
|
- Minimal PHP dependencies
|
||||||
|
- Efficient routing with FastRoute
|
||||||
|
- Client-side caching for static assets
|
||||||
BIN
collections.sqlite
Executable file
BIN
collections.sqlite
Executable file
Binary file not shown.
@@ -46,6 +46,6 @@ $twig->addGlobal('app_name', APP_NAME);
|
|||||||
$twig->addGlobal('delete_part_confirm', $translator->trans('Are you sure you want to delete this part?'));
|
$twig->addGlobal('delete_part_confirm', $translator->trans('Are you sure you want to delete this part?'));
|
||||||
$twig->addGlobal('delete_category_confirm', $translator->trans('Are you sure you want to delete this category?'));
|
$twig->addGlobal('delete_category_confirm', $translator->trans('Are you sure you want to delete this category?'));
|
||||||
|
|
||||||
// Build category tree for sidebar
|
// Sidebar will be built dynamically via AJAX
|
||||||
$sidebarHtml = '<ul class="nav flex-column nav-pills"><li class="nav-item"><a class="nav-link" href="#" data-route="/">Overview</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/categories">Categories</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/parts">Parts</a></li></ul>';
|
$sidebarHtml = '<div id="sidebar-tree">Loading...</div>';
|
||||||
$twig->addGlobal('sidebar_html', $sidebarHtml);
|
$twig->addGlobal('sidebar_html', $sidebarHtml);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Router;
|
|||||||
use App\Controllers\ItemController;
|
use App\Controllers\ItemController;
|
||||||
use App\Controllers\CategoryController;
|
use App\Controllers\CategoryController;
|
||||||
use App\Database;
|
use App\Database;
|
||||||
|
use App\Models\Category;
|
||||||
|
|
||||||
// Initialize Database (ensures tables exist)
|
// Initialize Database (ensures tables exist)
|
||||||
Database::getInstance();
|
Database::getInstance();
|
||||||
@@ -40,25 +41,58 @@ $router->addRoute('GET', '/print/{id:\d+}', [ItemController::class, 'printQR']);
|
|||||||
// These routes return only the Twig block content, not the full layout.
|
// These routes return only the Twig block content, not the full layout.
|
||||||
$router->addRoute('GET', '/api/items', [ItemController::class, 'listItems']);
|
$router->addRoute('GET', '/api/items', [ItemController::class, 'listItems']);
|
||||||
$router->addRoute('GET', '/api/items/{id:\d+}', [ItemController::class, 'getItem']);
|
$router->addRoute('GET', '/api/items/{id:\d+}', [ItemController::class, 'getItem']);
|
||||||
$router->addRoute('GET', '/api/categories', [CategoryController::class, 'listCategories']);
|
|
||||||
$router->addRoute('GET', '/api/categories/list', [CategoryController::class, 'listCategoriesJson']);
|
$router->addRoute('GET', '/api/categories/list', [CategoryController::class, 'listCategoriesJson']);
|
||||||
$router->addRoute('GET', '/api/categories/{id}', [CategoryController::class, 'getCategory']);
|
$router->addRoute('GET', '/api/categories/{id}', [CategoryController::class, 'getCategory']);
|
||||||
$router->addRoute('GET', '/api/parts', [ItemController::class, 'renderAddForm']);
|
|
||||||
|
// Categories with optional filter
|
||||||
|
$router->addRoute('GET', '/api/categories', function() {
|
||||||
|
$categoryId = $_GET['category'] ?? null;
|
||||||
|
if ($categoryId) {
|
||||||
|
// Show category details with items
|
||||||
|
$controller = new CategoryController();
|
||||||
|
$controller->showCategory($categoryId);
|
||||||
|
} else {
|
||||||
|
// Show all categories
|
||||||
|
$controller = new CategoryController();
|
||||||
|
$controller->listCategories();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parts with optional item filter
|
||||||
|
$router->addRoute('GET', '/api/parts', function() {
|
||||||
|
$itemId = $_GET['item'] ?? null;
|
||||||
|
if ($itemId) {
|
||||||
|
// Show item details/edit form
|
||||||
|
$controller = new ItemController();
|
||||||
|
$controller->editItem($itemId);
|
||||||
|
} else {
|
||||||
|
// Show add form
|
||||||
|
$controller = new ItemController();
|
||||||
|
$controller->renderAddForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
$router->addRoute('GET', '/api/tree', function() {
|
$router->addRoute('GET', '/api/tree', function() {
|
||||||
try {
|
try {
|
||||||
$db = App\Database\Database::getInstance();
|
$db = Database::getInstance();
|
||||||
$categories = App\Models\Category::getAll($db);
|
$categories = Category::getAll($db);
|
||||||
|
|
||||||
function build_category_tree($categories, $parentId = null, &$visited = [], $depth = 0, &$nodeCount = 0) {
|
function build_category_tree($categories, $db, $parentId = null, $visited = [], $depth = 0, $nodeCount = 0) {
|
||||||
if ($depth > 5 || $nodeCount > 100) return [];
|
if ($depth > 5 || $nodeCount > 100) return [];
|
||||||
$tree = [];
|
$tree = [];
|
||||||
foreach ($categories as $cat) {
|
foreach ($categories as $cat) {
|
||||||
if ($cat['parent_id'] == $parentId && !in_array($cat['id'], $visited)) {
|
if ($cat['parent_id'] == $parentId && !in_array($cat['id'], $visited)) {
|
||||||
$visited[] = $cat['id'];
|
$visited[] = $cat['id'];
|
||||||
|
|
||||||
|
// Get items for this category
|
||||||
|
$itemsStmt = $db->prepare('SELECT id, name FROM items WHERE category_id = :category_id ORDER BY name');
|
||||||
|
$itemsStmt->execute([':category_id' => $cat['id']]);
|
||||||
|
$items = $itemsStmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$node = [
|
$node = [
|
||||||
'id' => $cat['id'],
|
'id' => $cat['id'],
|
||||||
'name' => $cat['name'],
|
'name' => $cat['name'],
|
||||||
'children' => build_category_tree($categories, $cat['id'], $visited, $depth + 1, $nodeCount)
|
'items' => $items,
|
||||||
|
'children' => build_category_tree($categories, $db, $cat['id'], $visited, $depth + 1, $nodeCount)
|
||||||
];
|
];
|
||||||
$tree[] = $node;
|
$tree[] = $node;
|
||||||
$nodeCount++;
|
$nodeCount++;
|
||||||
@@ -72,21 +106,39 @@ $router->addRoute('GET', '/api/tree', function() {
|
|||||||
if ($depth > 5) return '';
|
if ($depth > 5) return '';
|
||||||
$html = '';
|
$html = '';
|
||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
$html .= '<li>';
|
$html .= '<li class="nav-item">';
|
||||||
$html .= '<span class="category" onclick="toggleCategory(this)">' . htmlspecialchars($node['name']) . '</span>';
|
$html .= '<a class="nav-link category-link" href="#" data-route="/categories?category=' . $node['id'] . '">';
|
||||||
|
$html .= '<i class="bi bi-folder"></i> ' . htmlspecialchars($node['name']);
|
||||||
|
$html .= '</a>';
|
||||||
|
|
||||||
|
// Add items as sub-items
|
||||||
|
if (!empty($node['items'])) {
|
||||||
|
$html .= '<ul class="nav flex-column nav-pills ms-2">';
|
||||||
|
foreach ($node['items'] as $item) {
|
||||||
|
$html .= '<li class="nav-item">';
|
||||||
|
$html .= '<a class="nav-link item-link" href="#" data-route="/parts?item=' . $item['id'] . '">';
|
||||||
|
$html .= '<i class="bi bi-file-earmark"></i> ' . htmlspecialchars($item['name']);
|
||||||
|
$html .= '</a>';
|
||||||
|
$html .= '</li>';
|
||||||
|
}
|
||||||
|
$html .= '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcategories
|
||||||
if (!empty($node['children'])) {
|
if (!empty($node['children'])) {
|
||||||
$html .= '<ul style="display: none;">';
|
$html .= '<ul class="nav flex-column nav-pills ms-2">';
|
||||||
$html .= render_category_tree($node['children'], $depth + 1);
|
$html .= render_category_tree($node['children'], $depth + 1);
|
||||||
$html .= '</ul>';
|
$html .= '</ul>';
|
||||||
}
|
}
|
||||||
|
|
||||||
$html .= '</li>';
|
$html .= '</li>';
|
||||||
}
|
}
|
||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
$nodeCount = 0;
|
$nodeCount = 0;
|
||||||
$categoryTree = build_category_tree($categories, null, $visited = [], 0, $nodeCount);
|
$categoryTree = build_category_tree($categories, $db, null, [], 0, $nodeCount);
|
||||||
$html = '<ul class="category-tree">' . render_category_tree($categoryTree) . '</ul>';
|
$html = '<ul class="nav flex-column nav-pills">' . render_category_tree($categoryTree) . '</ul>';
|
||||||
if (strlen($html) > 10000) {
|
if (strlen($html) > 10000) {
|
||||||
$html = '<ul class="nav flex-column nav-pills"><li class="nav-item"><a class="nav-link" href="#" data-route="/">Overview</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/categories">Categories</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/parts">Parts</a></li></ul>';
|
$html = '<ul class="nav flex-column nav-pills"><li class="nav-item"><a class="nav-link" href="#" data-route="/">Overview</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/categories">Categories</a></li><li class="nav-item"><a class="nav-link" href="#" data-route="/parts">Parts</a></li></ul>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,12 +40,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
apiPath = '/api/items';
|
apiPath = '/api/items';
|
||||||
pageTitle = 'Overview';
|
pageTitle = 'Overview';
|
||||||
routePath = '/';
|
routePath = '/';
|
||||||
} else if (path === '/categories') {
|
} else if (path.startsWith('/categories')) {
|
||||||
apiPath = '/api/categories';
|
if (path.includes('?category=')) {
|
||||||
pageTitle = 'Categories';
|
const categoryId = path.split('?category=')[1];
|
||||||
} else if (path === '/parts') {
|
apiPath = '/api/categories?category=' + categoryId;
|
||||||
apiPath = '/api/parts';
|
pageTitle = 'Category Details';
|
||||||
pageTitle = 'Parts';
|
} else {
|
||||||
|
apiPath = '/api/categories';
|
||||||
|
pageTitle = 'Categories';
|
||||||
|
}
|
||||||
|
} else if (path.startsWith('/parts')) {
|
||||||
|
if (path.includes('?item=')) {
|
||||||
|
const itemId = path.split('?item=')[1];
|
||||||
|
apiPath = '/api/parts?item=' + itemId;
|
||||||
|
pageTitle = 'Part Details';
|
||||||
|
} else {
|
||||||
|
apiPath = '/api/parts';
|
||||||
|
pageTitle = 'Parts';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback
|
// Fallback
|
||||||
apiPath = '/api/items';
|
apiPath = '/api/items';
|
||||||
@@ -111,20 +123,23 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial page load
|
// Load category tree first
|
||||||
const initialPath = window.location.pathname === '/' ? '/items' : window.location.pathname;
|
|
||||||
fetchContent(initialPath, false);
|
|
||||||
|
|
||||||
// Load category tree
|
|
||||||
fetch('/api/tree')
|
fetch('/api/tree')
|
||||||
.then(response => response.text())
|
.then(response => response.text())
|
||||||
.then(html => {
|
.then(html => {
|
||||||
document.querySelector('.sidebar').innerHTML = html;
|
const sidebarTree = document.getElementById('sidebar-tree');
|
||||||
|
if (sidebarTree) {
|
||||||
|
sidebarTree.innerHTML = html;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading tree:', error);
|
console.error('Error loading tree:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initial page load
|
||||||
|
const initialPath = window.location.pathname === '/' ? '/items' : window.location.pathname;
|
||||||
|
fetchContent(initialPath, false);
|
||||||
|
|
||||||
// Page-specific scripts (e.g., form submissions)
|
// Page-specific scripts (e.g., form submissions)
|
||||||
function initPageSpecificScripts() {
|
function initPageSpecificScripts() {
|
||||||
// Overview page: filter form
|
// Overview page: filter form
|
||||||
@@ -172,6 +187,31 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.querySelectorAll('.delete-category-btn').forEach(btn => {
|
document.querySelectorAll('.delete-category-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', handleDeleteCategory);
|
btn.addEventListener('click', handleDeleteCategory);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Item edit page: delete button
|
||||||
|
const deleteItemBtn = document.getElementById('deleteItemBtn');
|
||||||
|
if (deleteItemBtn) {
|
||||||
|
deleteItemBtn.addEventListener('click', function() {
|
||||||
|
const itemId = document.getElementById('edit_item_id').value;
|
||||||
|
if (confirm(window.translations.deletePartConfirm)) {
|
||||||
|
fetch('/api/items/' + itemId, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
fetchContent('/', false);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Error deleting item');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Error deleting item');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler functions
|
// Handler functions
|
||||||
|
|||||||
@@ -23,4 +23,36 @@
|
|||||||
|
|
||||||
.item-link:hover {
|
.item-link:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tree structure styling */
|
||||||
|
.category-link {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057 !important;
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-link:hover {
|
||||||
|
background-color: #e9ecef !important;
|
||||||
|
color: #212529 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-link {
|
||||||
|
font-weight: 400;
|
||||||
|
color: #6c757d !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-link:hover {
|
||||||
|
background-color: #f8f9fa !important;
|
||||||
|
color: #007bff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bi {
|
||||||
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,30 @@ class CategoryController
|
|||||||
echo $twig->render('layout.twig', ['active_page' => 'categories']);
|
echo $twig->render('layout.twig', ['active_page' => 'categories']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show category with items (AJAX)
|
||||||
|
public function showCategory($categoryId)
|
||||||
|
{
|
||||||
|
global $twig;
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$category = Category::getById($db, $categoryId);
|
||||||
|
if (!$category) {
|
||||||
|
echo '<div class="alert alert-danger">Category not found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items in this category
|
||||||
|
$items = \App\Models\Item::getAllFiltered($db, '', $categoryId);
|
||||||
|
|
||||||
|
echo $twig->render('partials/category_detail.twig', [
|
||||||
|
'category' => $category,
|
||||||
|
'items' => $items
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo '<div class="alert alert-danger">Error loading category: ' . htmlspecialchars($e->getMessage()) . '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Get single category (JSON)
|
// Get single category (JSON)
|
||||||
|
|||||||
@@ -21,6 +21,30 @@ class ItemController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit item form (AJAX)
|
||||||
|
public function editItem($itemId)
|
||||||
|
{
|
||||||
|
global $twig;
|
||||||
|
try {
|
||||||
|
$db = Database::getInstance();
|
||||||
|
$item = Item::getById($db, $itemId);
|
||||||
|
if (!$item) {
|
||||||
|
echo '<div class="alert alert-danger">Item not found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all categories for the dropdown
|
||||||
|
$categories = Category::getAll($db);
|
||||||
|
|
||||||
|
echo $twig->render('partials/item_edit.twig', [
|
||||||
|
'item' => $item,
|
||||||
|
'categories' => $categories
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
echo '<div class="alert alert-danger">Error loading item: ' . htmlspecialchars($e->getMessage()) . '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Renders the full layout for add parts page
|
// Renders the full layout for add parts page
|
||||||
public static function addForm()
|
public static function addForm()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
<title>{{ app_name }}</title>
|
<title>{{ app_name }}</title>
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
<link rel="stylesheet" href="style.css"> <!-- Custom CSS -->
|
<link rel="stylesheet" href="style.css"> <!-- Custom CSS -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
39
templates/partials/category_detail.twig
Normal file
39
templates/partials/category_detail.twig
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>{{ category.name }}</h2>
|
||||||
|
<button class="btn btn-primary" onclick="fetchContent('/parts')">Add New Item</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Items in this Category</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if items|length > 0 %}
|
||||||
|
<div class="list-group">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">{{ item.name }}</h6>
|
||||||
|
{% if item.description %}
|
||||||
|
<p class="mb-1 text-muted">{{ item.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.location %}
|
||||||
|
<small class="text-muted">📍 {{ item.location }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary edit-btn" data-id="{{ item.id }}">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger delete-btn" data-id="{{ item.id }}">Delete</button>
|
||||||
|
{% if item.id_code %}
|
||||||
|
<a href="/print/{{ item.id }}" target="_blank" class="btn btn-sm btn-outline-secondary">QR</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">No items in this category yet.</p>
|
||||||
|
<button class="btn btn-primary" onclick="fetchContent('/parts')">Add First Item</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
58
templates/partials/item_edit.twig
Normal file
58
templates/partials/item_edit.twig
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Edit Item: {{ item.name }}</h2>
|
||||||
|
<button class="btn btn-secondary" onclick="fetchContent('/')">Back to Overview</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="editItemForm">
|
||||||
|
<input type="hidden" id="edit_item_id" value="{{ item.id }}">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_item_name" class="form-label">Name *</label>
|
||||||
|
<input type="text" class="form-control" id="edit_item_name" value="{{ item.name }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_item_category_id" class="form-label">Category</label>
|
||||||
|
<select class="form-select" id="edit_item_category_id">
|
||||||
|
<option value="">-- Select Category --</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category.id }}" {% if category.id == item.category_id %}selected{% endif %}>{{ category.path }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_location" class="form-label">Location</label>
|
||||||
|
<input type="text" class="form-control" id="edit_location" value="{{ item.location }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_item_description" class="form-label">Description</label>
|
||||||
|
<textarea class="form-control" id="edit_item_description" rows="4">{{ item.description }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="edit_image" class="form-label">Image</label>
|
||||||
|
<input type="file" class="form-control" id="edit_image" accept="image/*">
|
||||||
|
<div id="current_image" class="mt-2">
|
||||||
|
{% if item.image %}
|
||||||
|
<p>Current image:</p>
|
||||||
|
<img src="{{ item.image }}" style="max-width: 100px;">
|
||||||
|
{% else %}
|
||||||
|
<p>No image</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-primary" id="saveEditItemBtn">Save Changes</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="deleteItemBtn">Delete Item</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="fetchContent('/')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
Reference in New Issue
Block a user