Compare commits
1 Commits
codex/impl
...
codex/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3755435890 |
36
.env.example
Normal file
36
.env.example
Normal file
@@ -0,0 +1,36 @@
|
||||
APP_NAME="TuxiNet Portal"
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://portal.tuxinet.dk
|
||||
APP_TIMEZONE=UTC
|
||||
APP_LOCALE=en
|
||||
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=cms_portal
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_TIMEOUT=30
|
||||
MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs"
|
||||
MAIL_FROM_ADDRESS=no-reply@portal.tuxinet.dk
|
||||
MAIL_FROM_NAME="TuxiNet Portal"
|
||||
|
||||
MAX_UPLOAD_MB=20
|
||||
ALLOWED_MIME="image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip"
|
||||
ENABLE_2FA=false
|
||||
|
||||
SESSION_LIFETIME=120
|
||||
RATE_LIMIT_ATTEMPTS=5
|
||||
RATE_LIMIT_DECAY=60
|
||||
|
||||
BRAND_COMPANY="TuxiNet.dk"
|
||||
BRAND_DOMAIN=portal.tuxinet.dk
|
||||
BRAND_LOGO_PATH="/assets/branding/tuxinet-logo.svg"
|
||||
BRAND_EMAIL_SIGNATURE="TuxiNet.dk • portal.tuxinet.dk"
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/vendor/
|
||||
/.env
|
||||
/storage/
|
||||
/public/uploads/
|
||||
/.phpunit.result.cache
|
||||
77
README.md
77
README.md
@@ -1 +1,76 @@
|
||||
# CMS
|
||||
# TuxiNet Portal
|
||||
|
||||
This repository contains the groundwork for a dark-themed, responsive customer portal that unifies customer management, project tracking, and an issue/bug workflow. The stack uses vanilla PHP 8.2+ with a lightweight framework layer tailored for Simply.com hosting environments and the `portal.tuxinet.dk` subdomain deployment.
|
||||
|
||||
## Features included in this initial foundation
|
||||
|
||||
- Minimal front controller with PSR-4 autoloading and route registration.
|
||||
- Session-backed authentication service with Argon2id password hashing.
|
||||
- Dark mode responsive layout scaffold for login and dashboard views.
|
||||
- Config-driven environment loading via `.env` file.
|
||||
- MySQL-compatible migration runner with dedicated migrations for all core domain tables (customers, projects, issues, comments, attachments, subscriptions, activity, audit, and settings).
|
||||
- Configurable mail service built on PHPMailer with SMTP and sendmail support tailored for Simply.com hosting.
|
||||
- TuxiNet.dk branding assets plus shared document templates for offers and invoices that automatically include the company logo.
|
||||
- Extensible service, model, and middleware layers prepared for RBAC, notifications, and future modules.
|
||||
|
||||
## Getting started
|
||||
|
||||
1. Copy `.env.example` to `.env` and adjust connection credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Install dependencies (including PHPMailer):
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
3. Configure the database credentials for your Simply.com MySQL server and adjust upload/mail parameters in `.env`.
|
||||
|
||||
4. Review the branding block at the bottom of `.env` or `config/branding.php` if you need to update the default company name, public domain, or logo path. By default the portal ships with the TuxiNet.dk logo located at `public/assets/branding/tuxinet-logo.svg`, which is automatically included on all rendered offers and invoices.
|
||||
|
||||
5. Run migrations to create all database tables:
|
||||
|
||||
```bash
|
||||
php bin/migrate
|
||||
```
|
||||
|
||||
6. Point your web server document root to `public/` and ensure the vhost/subdomain is `portal.tuxinet.dk` so absolute URLs render correctly.
|
||||
|
||||
7. Create an initial sysadmin user manually (e.g. via SQL) and log in using `/login`.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Implement RBAC-aware route middleware for each role and feature module.
|
||||
- Flesh out controllers/views for customers, projects, issues, and settings management.
|
||||
- Integrate mail notifications, subscription logic, and audit logging handlers.
|
||||
- Add file upload handling with MIME validation and thumbnail generation.
|
||||
- Build CSV export, saved filters, and read-only sharing features.
|
||||
|
||||
## Branding & document templates
|
||||
|
||||
- The active logo lives at `public/assets/branding/tuxinet-logo.svg` and is referenced via `config('branding.logo_path')`.
|
||||
- Update `.env` if you need to change the brand copy or email signature. The defaults reflect the TuxiNet.dk identity and the `portal.tuxinet.dk` hostname.
|
||||
- Offer and invoice renderers can target the shared templates at `resources/views/documents/offer.php` and `resources/views/documents/invoice.php`. Both templates automatically include the logo header defined in `resources/views/documents/layout.php`.
|
||||
|
||||
## Folder structure
|
||||
|
||||
```
|
||||
app/
|
||||
Controllers/
|
||||
Core/
|
||||
Http/Middleware/
|
||||
Models/
|
||||
Services/
|
||||
Support/
|
||||
bootstrap/
|
||||
config/
|
||||
database/migrations/
|
||||
public/
|
||||
resources/views/
|
||||
routes/
|
||||
```
|
||||
|
||||
This structure keeps the codebase organised and ready for incremental feature development across authentication, project management, and notification workflows.
|
||||
|
||||
36
app/Controllers/AuthController.php
Normal file
36
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function showLogin(): Response
|
||||
{
|
||||
return $this->view('auth/login');
|
||||
}
|
||||
|
||||
public function login(): Response
|
||||
{
|
||||
$email = $_POST['email'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
$auth = AuthService::getInstance();
|
||||
if ($auth->attempt($email, $password)) {
|
||||
return $this->redirect('/');
|
||||
}
|
||||
|
||||
return $this->view('auth/login', [
|
||||
'error' => 'Invalid credentials or inactive account.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(): Response
|
||||
{
|
||||
AuthService::getInstance()->logout();
|
||||
return $this->redirect('/login');
|
||||
}
|
||||
}
|
||||
19
app/Controllers/DashboardController.php
Normal file
19
app/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Core\Controller;
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$user = AuthService::getInstance()->user();
|
||||
|
||||
return $this->view('dashboard/index', [
|
||||
'user' => $user,
|
||||
]);
|
||||
}
|
||||
}
|
||||
93
app/Core/Config.php
Normal file
93
app/Core/Config.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Config
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $values = [];
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadEnv();
|
||||
$this->loadConfigFiles();
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function get(string $key, $default = null)
|
||||
{
|
||||
$segments = explode('.', $key);
|
||||
$value = $this->values;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (!is_array($value) || !array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public function set(array $config): void
|
||||
{
|
||||
$this->values = array_replace_recursive($this->values, $config);
|
||||
}
|
||||
|
||||
private function loadEnv(): void
|
||||
{
|
||||
$envPath = base_path('.env');
|
||||
if (!file_exists($envPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
if ($lines === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (str_starts_with(trim($line), '#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$name, $value] = array_map('trim', explode('=', $line, 2) + [1 => '']);
|
||||
$value = trim($value, "\"' ");
|
||||
$_ENV[$name] = $value;
|
||||
putenv("{$name}={$value}");
|
||||
}
|
||||
}
|
||||
|
||||
private function loadConfigFiles(): void
|
||||
{
|
||||
$configDir = base_path('config');
|
||||
if (!is_dir($configDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($configDir . '/*.php');
|
||||
if ($files === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($files as $file) {
|
||||
$key = basename($file, '.php');
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = require $file;
|
||||
$this->values[$key] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
app/Core/Controller.php
Normal file
17
app/Core/Controller.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Controller
|
||||
{
|
||||
protected function view(string $template, array $data = []): Response
|
||||
{
|
||||
$content = View::render($template, $data);
|
||||
return new Response($content);
|
||||
}
|
||||
|
||||
protected function redirect(string $url): Response
|
||||
{
|
||||
return new Response('', 302, ['Location' => $url]);
|
||||
}
|
||||
}
|
||||
44
app/Core/Database.php
Normal file
44
app/Core/Database.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
|
||||
class Database
|
||||
{
|
||||
private static ?PDO $connection = null;
|
||||
|
||||
public static function connection(): PDO
|
||||
{
|
||||
if (self::$connection === null) {
|
||||
$config = config('database');
|
||||
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
|
||||
$config['host'],
|
||||
$config['port'],
|
||||
$config['database']
|
||||
);
|
||||
|
||||
$options = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
|
||||
try {
|
||||
self::$connection = new PDO(
|
||||
$dsn,
|
||||
$config['username'],
|
||||
$config['password'],
|
||||
$options
|
||||
);
|
||||
} catch (PDOException $exception) {
|
||||
throw new \RuntimeException('Database connection failed: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return self::$connection;
|
||||
}
|
||||
}
|
||||
39
app/Core/Response.php
Normal file
39
app/Core/Response.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Response
|
||||
{
|
||||
private string $body;
|
||||
private int $status;
|
||||
/** @var array<string, string> */
|
||||
private array $headers;
|
||||
|
||||
/**
|
||||
* @param array<string, string> $headers
|
||||
*/
|
||||
public function __construct(string $body = '', int $status = 200, array $headers = [])
|
||||
{
|
||||
$this->body = $body;
|
||||
$this->status = $status;
|
||||
$this->headers = $headers;
|
||||
}
|
||||
|
||||
public function getBody(): string
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
|
||||
public function getStatus(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return $this->headers;
|
||||
}
|
||||
}
|
||||
97
app/Core/Router.php
Normal file
97
app/Core/Router.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class Router
|
||||
{
|
||||
/** @var array<string, array<int, array{path:string,action:callable,middleware:array}>> */
|
||||
private array $routes = [];
|
||||
|
||||
/** @var array<int, array<int, class-string>> */
|
||||
private array $middlewareStack = [];
|
||||
|
||||
public function get(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function post(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function put(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function delete(string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $action, $middleware);
|
||||
}
|
||||
|
||||
public function group(array $attributes, \Closure $callback): void
|
||||
{
|
||||
$groupMiddleware = $attributes['middleware'] ?? [];
|
||||
$this->middlewareStack[] = $groupMiddleware;
|
||||
$callback($this);
|
||||
array_pop($this->middlewareStack);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, class-string> $middleware
|
||||
*/
|
||||
private function addRoute(string $method, string $path, callable $action, array $middleware = []): void
|
||||
{
|
||||
$stackedMiddleware = [];
|
||||
foreach ($this->middlewareStack as $group) {
|
||||
$stackedMiddleware = array_merge($stackedMiddleware, $group);
|
||||
}
|
||||
$combinedMiddleware = array_merge($stackedMiddleware, $middleware);
|
||||
|
||||
$this->routes[$method][] = [
|
||||
'path' => $path,
|
||||
'action' => $action,
|
||||
'middleware' => $combinedMiddleware,
|
||||
];
|
||||
}
|
||||
|
||||
public function dispatch(string $method, string $uri): Response
|
||||
{
|
||||
$uri = parse_url($uri, PHP_URL_PATH) ?? '/';
|
||||
$routes = $this->routes[$method] ?? [];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$pattern = '#^' . preg_replace('#\{(.*?)\}#', '(?P<$1>[^/]+)', $route['path']) . '$#';
|
||||
if (preg_match($pattern, $uri, $matches)) {
|
||||
$params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||
$handler = $this->applyMiddleware($route['action'], $route['middleware']);
|
||||
$result = $handler($params);
|
||||
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new Response((string) $result);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not Found', 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable $action
|
||||
* @param array<int, class-string> $middleware
|
||||
*/
|
||||
private function applyMiddleware(callable $action, array $middleware): callable
|
||||
{
|
||||
return array_reduce(
|
||||
array_reverse($middleware),
|
||||
function ($next, $middlewareClass) {
|
||||
$middlewareInstance = new $middlewareClass();
|
||||
return fn(array $params) => $middlewareInstance->handle($params, $next);
|
||||
},
|
||||
$action
|
||||
);
|
||||
}
|
||||
}
|
||||
19
app/Core/View.php
Normal file
19
app/Core/View.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
class View
|
||||
{
|
||||
public static function render(string $template, array $data = []): string
|
||||
{
|
||||
$path = base_path('resources/views/' . $template . '.php');
|
||||
if (!file_exists($path)) {
|
||||
throw new \RuntimeException('View not found: ' . $template);
|
||||
}
|
||||
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include $path;
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
19
app/Http/Middleware/AuthMiddleware.php
Normal file
19
app/Http/Middleware/AuthMiddleware.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Core\Response;
|
||||
use App\Services\AuthService;
|
||||
|
||||
class AuthMiddleware
|
||||
{
|
||||
public function handle(array $params, callable $next): Response
|
||||
{
|
||||
$auth = AuthService::getInstance();
|
||||
if (!$auth->check()) {
|
||||
return new Response('', 302, ['Location' => '/login']);
|
||||
}
|
||||
|
||||
return $next($params);
|
||||
}
|
||||
}
|
||||
38
app/Models/Model.php
Normal file
38
app/Models/Model.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Core\Database;
|
||||
use PDO;
|
||||
|
||||
abstract class Model
|
||||
{
|
||||
protected static string $table;
|
||||
protected static string $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* @return array<int, static>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$stmt = Database::connection()->query('SELECT * FROM ' . static::$table);
|
||||
$rows = $stmt->fetchAll();
|
||||
return array_map(fn($row) => static::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
public static function find(int $id): ?static
|
||||
{
|
||||
$stmt = Database::connection()->prepare('SELECT * FROM ' . static::$table . ' WHERE ' . static::$primaryKey . ' = :id LIMIT 1');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
return static::fromArray($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
abstract protected static function fromArray(array $attributes): static;
|
||||
}
|
||||
54
app/Models/User.php
Normal file
54
app/Models/User.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User extends Model
|
||||
{
|
||||
protected static string $table = 'users';
|
||||
|
||||
public int $id;
|
||||
public string $role;
|
||||
public string $name;
|
||||
public string $email;
|
||||
public string $pass_hash;
|
||||
public ?string $phone;
|
||||
public ?string $address;
|
||||
public ?int $customer_id;
|
||||
public bool $is_active;
|
||||
public ?string $twofa_secret;
|
||||
public string $created_at;
|
||||
public string $updated_at;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
protected static function fromArray(array $attributes): static
|
||||
{
|
||||
$user = new static();
|
||||
$user->id = (int) $attributes['id'];
|
||||
$user->role = (string) $attributes['role'];
|
||||
$user->name = (string) $attributes['name'];
|
||||
$user->email = (string) $attributes['email'];
|
||||
$user->pass_hash = (string) $attributes['pass_hash'];
|
||||
$user->phone = $attributes['phone'] ?? null;
|
||||
$user->address = $attributes['address'] ?? null;
|
||||
$user->customer_id = isset($attributes['customer_id']) ? (int) $attributes['customer_id'] : null;
|
||||
$user->is_active = (bool) $attributes['is_active'];
|
||||
$user->twofa_secret = $attributes['twofa_secret'] ?? null;
|
||||
$user->created_at = (string) $attributes['created_at'];
|
||||
$user->updated_at = (string) $attributes['updated_at'];
|
||||
return $user;
|
||||
}
|
||||
|
||||
public static function findByEmail(string $email): ?self
|
||||
{
|
||||
$stmt = \App\Core\Database::connection()->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
|
||||
$stmt->execute(['email' => $email]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::fromArray($row);
|
||||
}
|
||||
}
|
||||
69
app/Services/AuthService.php
Normal file
69
app/Services/AuthService.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Support\PasswordHasher;
|
||||
|
||||
class AuthService
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
private ?User $user = null;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$this->user = User::find((int) $_SESSION['user_id']);
|
||||
}
|
||||
}
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function attempt(string $email, string $password): bool
|
||||
{
|
||||
$user = User::findByEmail($email);
|
||||
if (!$user || !$user->is_active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!PasswordHasher::verify($password, $user->pass_hash)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$_SESSION['user_id'] = $user->id;
|
||||
$this->user = $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], $params['secure'], $params['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
return $this->user !== null;
|
||||
}
|
||||
|
||||
public function user(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
125
app/Services/MailService.php
Normal file
125
app/Services/MailService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use PHPMailer\PHPMailer\Exception as MailException;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
|
||||
class MailService
|
||||
{
|
||||
/**
|
||||
* @param string|array<int|string, string|array{address:string,name?:string}> $to
|
||||
* @param array<int|string, string|array{address:string,name?:string}> $cc
|
||||
* @param array<int|string, string|array{address:string,name?:string}> $bcc
|
||||
*/
|
||||
public function send($to, string $subject, string $htmlBody, ?string $textBody = null, array $cc = [], array $bcc = []): void
|
||||
{
|
||||
$config = config('mail');
|
||||
$mailer = new PHPMailer(true);
|
||||
|
||||
$mailer->CharSet = 'UTF-8';
|
||||
$mailer->isHTML(true);
|
||||
|
||||
$driver = strtolower((string)($config['driver'] ?? 'smtp'));
|
||||
|
||||
if ($driver === 'sendmail') {
|
||||
$mailer->isSendmail();
|
||||
if (!empty($config['sendmail_path'])) {
|
||||
$mailer->Sendmail = $config['sendmail_path'];
|
||||
}
|
||||
} elseif ($driver === 'mail') {
|
||||
$mailer->isMail();
|
||||
} else {
|
||||
$mailer->isSMTP();
|
||||
$mailer->Host = (string)($config['host'] ?? 'localhost');
|
||||
$mailer->Port = (int)($config['port'] ?? 25);
|
||||
$mailer->Timeout = (int)($config['timeout'] ?? 30);
|
||||
|
||||
$username = $config['username'] ?? null;
|
||||
$password = $config['password'] ?? null;
|
||||
if ($username) {
|
||||
$mailer->SMTPAuth = true;
|
||||
$mailer->Username = $username;
|
||||
$mailer->Password = (string)$password;
|
||||
} else {
|
||||
$mailer->SMTPAuth = false;
|
||||
}
|
||||
|
||||
$encryption = strtolower((string)($config['encryption'] ?? ''));
|
||||
if ($encryption === 'ssl') {
|
||||
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
|
||||
} elseif ($encryption === 'tls') {
|
||||
$mailer->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
|
||||
}
|
||||
}
|
||||
|
||||
$fromAddress = $config['from']['address'] ?? null;
|
||||
$fromName = $config['from']['name'] ?? '';
|
||||
if ($fromAddress) {
|
||||
$mailer->setFrom($fromAddress, $fromName ?: $fromAddress);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($to) as $recipient) {
|
||||
$mailer->addAddress($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($cc) as $recipient) {
|
||||
$mailer->addCC($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
foreach ($this->normalizeRecipients($bcc) as $recipient) {
|
||||
$mailer->addBCC($recipient['address'], $recipient['name']);
|
||||
}
|
||||
|
||||
$mailer->Subject = $subject;
|
||||
$mailer->Body = $htmlBody;
|
||||
$mailer->AltBody = $textBody ?? strip_tags($htmlBody);
|
||||
|
||||
try {
|
||||
$mailer->send();
|
||||
} catch (MailException $exception) {
|
||||
throw new \RuntimeException('Unable to send mail: ' . $exception->getMessage(), previous: $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<int|string, string|array{address:string,name?:string}> $addresses
|
||||
* @return list<array{address:string,name:string}>
|
||||
*/
|
||||
private function normalizeRecipients($addresses): array
|
||||
{
|
||||
if ($addresses === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!is_array($addresses)) {
|
||||
return [['address' => (string)$addresses, 'name' => '']];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($addresses as $key => $value) {
|
||||
if (is_array($value)) {
|
||||
$address = $value['address'] ?? null;
|
||||
if (!$address) {
|
||||
continue;
|
||||
}
|
||||
$normalized[] = [
|
||||
'address' => (string)$address,
|
||||
'name' => (string)($value['name'] ?? ''),
|
||||
];
|
||||
} elseif (is_string($key) && is_string($value)) {
|
||||
$normalized[] = [
|
||||
'address' => $key,
|
||||
'name' => $value,
|
||||
];
|
||||
} else {
|
||||
$normalized[] = [
|
||||
'address' => (string)$value,
|
||||
'name' => '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
16
app/Support/PasswordHasher.php
Normal file
16
app/Support/PasswordHasher.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class PasswordHasher
|
||||
{
|
||||
public static function make(string $password): string
|
||||
{
|
||||
return password_hash($password, PASSWORD_ARGON2ID);
|
||||
}
|
||||
|
||||
public static function verify(string $password, string $hash): bool
|
||||
{
|
||||
return password_verify($password, $hash);
|
||||
}
|
||||
}
|
||||
43
bin/migrate
Executable file
43
bin/migrate
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
require __DIR__ . '/../bootstrap/autoload.php';
|
||||
|
||||
use App\Core\Database;
|
||||
|
||||
date_default_timezone_set(config('app.timezone', 'UTC'));
|
||||
|
||||
$pdo = Database::connection();
|
||||
$config = config('database');
|
||||
$migrationsTable = $config['migrations_table'];
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS {$migrationsTable} (id INT AUTO_INCREMENT PRIMARY KEY, migration VARCHAR(255) NOT NULL, ran_at DATETIME NOT NULL)");
|
||||
|
||||
$statement = $pdo->query("SELECT migration FROM {$migrationsTable}");
|
||||
$ran = $statement ? $statement->fetchAll(PDO::FETCH_COLUMN) : [];
|
||||
|
||||
$files = glob(base_path('database/migrations/*.php'));
|
||||
sort($files);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$name = basename($file);
|
||||
if (in_array($name, $ran, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$migration = require $file;
|
||||
if (!is_callable($migration)) {
|
||||
throw new RuntimeException("Migration {$name} must return a callable.");
|
||||
}
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$migration($pdo);
|
||||
$stmt = $pdo->prepare("INSERT INTO {$migrationsTable} (migration, ran_at) VALUES (:migration, :ran_at)");
|
||||
$stmt->execute(['migration' => $name, 'ran_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s')]);
|
||||
$pdo->commit();
|
||||
echo "Migrated: {$name}\n";
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
fwrite(STDERR, "Failed: {$name} - {$e->getMessage()}\n");
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
19
bootstrap/autoload.php
Normal file
19
bootstrap/autoload.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$prefix = 'App\\';
|
||||
$baseDir = __DIR__ . '/../app/';
|
||||
|
||||
$len = strlen($prefix);
|
||||
if (strncmp($prefix, $class, $len) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$relativeClass = substr($class, $len);
|
||||
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
|
||||
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
}
|
||||
});
|
||||
|
||||
require_once __DIR__ . '/helpers.php';
|
||||
29
bootstrap/helpers.php
Normal file
29
bootstrap/helpers.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Core\Config;
|
||||
|
||||
if (!function_exists('config')) {
|
||||
function config(string $key, $default = null)
|
||||
{
|
||||
return Config::getInstance()->get($key, $default);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('env')) {
|
||||
function env(string $key, $default = null)
|
||||
{
|
||||
$value = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key);
|
||||
if ($value === false || $value === null) {
|
||||
return $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('base_path')) {
|
||||
function base_path(string $path = ''): string
|
||||
{
|
||||
$base = realpath(__DIR__ . '/..');
|
||||
return $path ? $base . '/' . ltrim($path, '/') : $base;
|
||||
}
|
||||
}
|
||||
14
composer.json
Normal file
14
composer.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "cms/portal",
|
||||
"description": "Customer portal, bug tracker, and light project management platform",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"phpmailer/phpmailer": "^6.9"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
config/app.php
Normal file
10
config/app.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => env('APP_NAME', 'TuxiNet Portal'),
|
||||
'env' => env('APP_ENV', 'local'),
|
||||
'debug' => (bool) env('APP_DEBUG', true),
|
||||
'url' => env('APP_URL', 'https://portal.tuxinet.dk'),
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
];
|
||||
8
config/branding.php
Normal file
8
config/branding.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'company' => env('BRAND_COMPANY', 'TuxiNet.dk'),
|
||||
'domain' => env('BRAND_DOMAIN', 'portal.tuxinet.dk'),
|
||||
'logo_path' => env('BRAND_LOGO_PATH', '/assets/branding/tuxinet-logo.svg'),
|
||||
'email_signature' => env('BRAND_EMAIL_SIGNATURE', "TuxiNet.dk • portal.tuxinet.dk"),
|
||||
];
|
||||
12
config/database.php
Normal file
12
config/database.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'cms_portal'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'migrations_table' => 'migrations',
|
||||
];
|
||||
7
config/files.php
Normal file
7
config/files.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'max_upload_mb' => (int) env('MAX_UPLOAD_MB', 20),
|
||||
'allowed_mime' => array_filter(array_map('trim', explode(',', env('ALLOWED_MIME', 'image/png,image/jpeg,image/gif,image/webp,application/pdf,application/zip')))),
|
||||
'storage_path' => base_path('public/uploads'),
|
||||
];
|
||||
16
config/mail.php
Normal file
16
config/mail.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'driver' => env('MAIL_DRIVER', 'smtp'),
|
||||
'host' => env('MAIL_HOST', 'localhost'),
|
||||
'port' => env('MAIL_PORT', 25),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'encryption' => env('MAIL_ENCRYPTION'),
|
||||
'timeout' => env('MAIL_TIMEOUT', 30),
|
||||
'sendmail_path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs'),
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'no-reply@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'CMS Portal'),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE customers (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
orgno VARCHAR(100) NOT NULL,
|
||||
billing_email VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
23
database/migrations/2024_01_01_000002_create_users_table.php
Normal file
23
database/migrations/2024_01_01_000002_create_users_table.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE users (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
role ENUM('sysadmin','developer','customer') NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
pass_hash VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50) NULL,
|
||||
address VARCHAR(255) NULL,
|
||||
customer_id INT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
twofa_secret VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_users_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE projects (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
customer_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
status ENUM('active','archived') NOT NULL DEFAULT 'active',
|
||||
created_by INT UNSIGNED NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_projects_customer FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_projects_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_projects_customer_status (customer_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE issues (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
type ENUM('bug','feature','task') NOT NULL,
|
||||
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
||||
status ENUM('new','in_progress','in_review','resolved','closed') NOT NULL DEFAULT 'new',
|
||||
assignee_id INT UNSIGNED NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
environment VARCHAR(50) NULL,
|
||||
version VARCHAR(100) NULL,
|
||||
steps_to_reproduce TEXT NULL,
|
||||
created_by INT UNSIGNED NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_issues_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_issues_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_issues_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_issues_project_status (project_id, status),
|
||||
INDEX idx_issues_priority (priority),
|
||||
INDEX idx_issues_assignee (assignee_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE labels (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
color VARCHAR(7) NOT NULL,
|
||||
CONSTRAINT fk_labels_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE issue_labels (
|
||||
issue_id INT UNSIGNED NOT NULL,
|
||||
label_id INT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (issue_id, label_id),
|
||||
CONSTRAINT fk_issue_labels_issue FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_issue_labels_label FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE comments (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
issue_id INT UNSIGNED NOT NULL,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_comments_issue FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_comments_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_comments_issue_created (issue_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE attachments (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
comment_id INT UNSIGNED NOT NULL,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(255) NOT NULL,
|
||||
size INT UNSIGNED NOT NULL,
|
||||
mime VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_attachments_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE subscriptions (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
project_id INT UNSIGNED NULL,
|
||||
issue_id INT UNSIGNED NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_subscriptions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_subscriptions_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_subscriptions_issue FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
|
||||
INDEX idx_subscriptions_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE activity (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INT UNSIGNED NOT NULL,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
meta_json JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_activity_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
INDEX idx_activity_entity (entity_type, entity_id),
|
||||
INDEX idx_activity_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
19
database/migrations/2024_01_01_000011_create_audit_table.php
Normal file
19
database/migrations/2024_01_01_000011_create_audit_table.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE audit (
|
||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT UNSIGNED NULL,
|
||||
entity VARCHAR(100) NOT NULL,
|
||||
entity_id INT UNSIGNED NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
meta_json JSON NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_audit_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
INDEX idx_audit_entity (entity, entity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use PDO;
|
||||
|
||||
return function (PDO $pdo): void {
|
||||
$pdo->exec(<<<SQL
|
||||
CREATE TABLE settings (
|
||||
`key` VARCHAR(100) PRIMARY KEY,
|
||||
`value` TEXT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
SQL);
|
||||
};
|
||||
6
public/.htaccess
Normal file
6
public/.htaccess
Normal file
@@ -0,0 +1,6 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
</IfModule>
|
||||
40
public/assets/branding/tuxinet-logo.svg
Normal file
40
public/assets/branding/tuxinet-logo.svg
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad-background" x1="256" y1="32" x2="256" y2="480" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1f2937"/>
|
||||
<stop offset="1" stop-color="#0f172a"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad-hex" x1="256" y1="96" x2="256" y2="416" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#60a5fa"/>
|
||||
<stop offset="1" stop-color="#38bdf8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="grad-node" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#38bdf8"/>
|
||||
<stop offset="1" stop-color="#a855f7"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
|
||||
<feDropShadow dx="0" dy="12" stdDeviation="16" flood-color="#020617" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="64" fill="url(#grad-background)"/>
|
||||
<g filter="url(#shadow)">
|
||||
<path d="M256 112L372 180V332L256 400L140 332V180L256 112Z" fill="#020617" stroke="url(#grad-hex)" stroke-width="18"/>
|
||||
<path d="M256 168C238.327 168 224 182.327 224 200C224 213.642 232.948 225.476 245.6 229.6V292.4C232.948 296.524 224 308.358 224 322C224 339.673 238.327 354 256 354C273.673 354 288 339.673 288 322C288 308.358 279.052 296.524 266.4 292.4V229.6C279.052 225.476 288 213.642 288 200C288 182.327 273.673 168 256 168Z" stroke="url(#grad-hex)" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
<circle cx="256" cy="200" r="18" fill="url(#grad-node)"/>
|
||||
<circle cx="256" cy="322" r="18" fill="url(#grad-node)"/>
|
||||
<path d="M256 200L196 160" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M256 200L316 160" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M256 322L196 362" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M256 322L316 362" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="196" cy="160" r="14" fill="url(#grad-node)"/>
|
||||
<circle cx="316" cy="160" r="14" fill="url(#grad-node)"/>
|
||||
<circle cx="196" cy="362" r="14" fill="url(#grad-node)"/>
|
||||
<circle cx="316" cy="362" r="14" fill="url(#grad-node)"/>
|
||||
<path d="M256 200V152" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<path d="M256 322V370" stroke="url(#grad-hex)" stroke-width="10" stroke-linecap="round"/>
|
||||
<circle cx="256" cy="142" r="12" fill="url(#grad-node)"/>
|
||||
<circle cx="256" cy="380" r="12" fill="url(#grad-node)"/>
|
||||
</g>
|
||||
<text x="256" y="456" fill="#e2e8f0" font-family="'Inter', 'Segoe UI', sans-serif" font-size="64" font-weight="600" text-anchor="middle">TuxiNet.dk</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
18
public/index.php
Normal file
18
public/index.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Core\Config;
|
||||
use App\Core\Router;
|
||||
|
||||
require __DIR__ . '/../bootstrap/autoload.php';
|
||||
|
||||
Config::getInstance();
|
||||
|
||||
$router = new Router();
|
||||
require base_path('routes/web.php');
|
||||
|
||||
$response = $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
|
||||
http_response_code($response->getStatus());
|
||||
foreach ($response->getHeaders() as $header => $value) {
|
||||
header($header . ': ' . $value);
|
||||
}
|
||||
echo $response->getBody();
|
||||
24
resources/views/auth/login.php
Normal file
24
resources/views/auth/login.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php $title = 'Login'; ob_start(); ?>
|
||||
<div class="card" style="max-width: 420px; margin: 4rem auto;">
|
||||
<h2 style="margin-top: 0;">Log ind</h2>
|
||||
<p style="margin-top: 0; color: rgba(148, 163, 184, 0.75);">Adgang til TuxiNet kundeservice og udviklingsportalen.</p>
|
||||
|
||||
<?php if (!empty($error)): ?>
|
||||
<div class="alert"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div style="display: grid; gap: 1rem;">
|
||||
<label>
|
||||
<span style="display:block;margin-bottom:0.25rem;">Email</span>
|
||||
<input type="email" name="email" required style="width:100%;padding:0.75rem;border-radius:0.75rem;border:1px solid rgba(148,163,184,0.2);background:rgba(15,23,42,0.65);color:#e2e8f0;">
|
||||
</label>
|
||||
<label>
|
||||
<span style="display:block;margin-bottom:0.25rem;">Password</span>
|
||||
<input type="password" name="password" required style="width:100%;padding:0.75rem;border-radius:0.75rem;border:1px solid rgba(148,163,184,0.2);background:rgba(15,23,42,0.65);color:#e2e8f0;">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<?php $content = ob_get_clean(); include base_path('resources/views/layout/app.php'); ?>
|
||||
39
resources/views/dashboard/index.php
Normal file
39
resources/views/dashboard/index.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php $title = 'Dashboard'; ob_start(); ?>
|
||||
<section class="grid grid-2">
|
||||
<div class="card">
|
||||
<h2 style="margin-top: 0;">Welcome back, <?= htmlspecialchars($user?->name ?? 'User') ?> 👋</h2>
|
||||
<p style="color: rgba(148, 163, 184, 0.75);">
|
||||
This is the foundation for the combined customer portal, bug tracker, and project management experience. Build out your
|
||||
issue workflows, notifications, and reporting from here.
|
||||
</p>
|
||||
<ul style="margin: 1rem 0 0; padding-left: 1.2rem; color: rgba(148, 163, 184, 0.9);">
|
||||
<li>Access your assigned issues from the navigation.</li>
|
||||
<li>Switch between customers and projects seamlessly.</li>
|
||||
<li>Upload assets and collaborate via the real-time activity feeds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0;">System status</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Environment</td>
|
||||
<td><span class="badge"><?= htmlspecialchars(config('app.env')) ?></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Uploads directory</td>
|
||||
<td><?= htmlspecialchars(config('files.storage_path')) ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Max upload size</td>
|
||||
<td><?= (int) config('files.max_upload_mb') ?> MB</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Allowed MIME types</td>
|
||||
<td><?= htmlspecialchars(implode(', ', config('files.allowed_mime'))) ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php $content = ob_get_clean(); include base_path('resources/views/layout/app.php'); ?>
|
||||
115
resources/views/documents/invoice.php
Normal file
115
resources/views/documents/invoice.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/** @var array $invoice */
|
||||
$invoice = $invoice ?? [];
|
||||
$lineItems = $invoice['items'] ?? [];
|
||||
$customer = $invoice['customer'] ?? [];
|
||||
$invoiceNumber = $invoice['number'] ?? '';
|
||||
$title = 'Faktura #' . ($invoiceNumber !== '' ? $invoiceNumber : 'Udkast');
|
||||
ob_start();
|
||||
?>
|
||||
<h2 style="margin-top:0;">Faktura <?= htmlspecialchars($invoiceNumber !== '' ? $invoiceNumber : 'Udkast') ?></h2>
|
||||
<p style="margin:0 0 16px; color:rgba(148,163,184,0.85);">
|
||||
Fakturadato: <?= htmlspecialchars($invoice['issued_at'] ?? date('Y-m-d')) ?> ·
|
||||
Forfaldsdato: <?= htmlspecialchars($invoice['due_at'] ?? date('Y-m-d', strtotime('+14 days'))) ?>
|
||||
</p>
|
||||
<table class="meta">
|
||||
<tr>
|
||||
<th style="width:180px;">Kunde</th>
|
||||
<td><?= htmlspecialchars($customer['name'] ?? 'Angiv kunde') ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kontaktperson</th>
|
||||
<td><?= htmlspecialchars($customer['contact'] ?? $customer['email'] ?? 'Angiv kontakt') ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<td><?= htmlspecialchars($invoice['reference'] ?? 'Angiv reference') ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="text-align:left; color:rgba(226,232,240,0.85);">
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25);">Beskrivelse</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:100px;">Antal</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:140px;">Enhedspris</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:140px;">Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($lineItems)): ?>
|
||||
<tr>
|
||||
<td colspan="4" style="padding:24px 0; color:rgba(148,163,184,0.8); text-align:center;">Tilføj linjeelementer for at færdiggøre fakturaen.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($lineItems as $item): ?>
|
||||
<?php $qty = (float) ($item['quantity'] ?? 0); $price = (float) ($item['unit_price'] ?? 0); ?>
|
||||
<tr>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<strong><?= htmlspecialchars($item['name'] ?? 'Ydelse') ?></strong><br>
|
||||
<span style="color:rgba(148,163,184,0.75); font-size:0.9rem;">
|
||||
<?= nl2br(htmlspecialchars($item['description'] ?? 'Beskriv ydelsen.')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<?= number_format($qty, 2, ',', '.') ?>
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<?= number_format($price, 2, ',', '.') ?> kr.
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12); font-weight:600;">
|
||||
<?= number_format($qty * $price, 2, ',', '.') ?> kr.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<?php
|
||||
$subtotal = array_reduce($lineItems, fn ($carry, $item) => $carry + (float) ($item['quantity'] ?? 0) * (float) ($item['unit_price'] ?? 0), 0.0);
|
||||
$vatRate = (float) ($invoice['vat_rate'] ?? 25);
|
||||
$vatAmount = $subtotal * ($vatRate / 100);
|
||||
$total = $subtotal + $vatAmount;
|
||||
$paid = (float) ($invoice['paid'] ?? 0);
|
||||
$balance = $total - $paid;
|
||||
?>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label">Subtotal</td>
|
||||
<td><?= number_format($subtotal, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Moms (<?= number_format($vatRate, 0) ?>%)</td>
|
||||
<td><?= number_format($vatAmount, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Total</td>
|
||||
<td><?= number_format($total, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Betalt</td>
|
||||
<td><?= number_format($paid, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label" style="font-weight:600;">Restbeløb</td>
|
||||
<td style="font-weight:700; color:#facc15;">
|
||||
<?= number_format($balance, 2, ',', '.') ?> kr.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (!empty($invoice['notes'])): ?>
|
||||
<section style="margin-top:32px;">
|
||||
<h3 style="margin-bottom:8px;">Bemærkninger</h3>
|
||||
<p style="color:rgba(148,163,184,0.85);"><?= nl2br(htmlspecialchars($invoice['notes'])) ?></p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($invoice['payment_details'])): ?>
|
||||
<section style="margin-top:32px;">
|
||||
<h3 style="margin-bottom:8px;">Betalingsinformation</h3>
|
||||
<p style="color:rgba(148,163,184,0.85);"><?= nl2br(htmlspecialchars($invoice['payment_details'])) ?></p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include base_path('resources/views/documents/layout.php');
|
||||
102
resources/views/documents/layout.php
Normal file
102
resources/views/documents/layout.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
$brand = [
|
||||
'company' => config('branding.company'),
|
||||
'domain' => config('branding.domain'),
|
||||
'logo' => config('branding.logo_path'),
|
||||
'email_signature' => config('branding.email_signature'),
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title><?= htmlspecialchars($title ?? $brand['company'] . ' Document') ?></title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 48px 64px;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
header img {
|
||||
height: 72px;
|
||||
width: auto;
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
padding: 0.45rem 0.65rem;
|
||||
}
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 2.15rem;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
color: rgba(148, 163, 184, 0.9);
|
||||
}
|
||||
main {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
footer {
|
||||
margin-top: 48px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(148, 163, 184, 0.75);
|
||||
text-align: center;
|
||||
}
|
||||
table.meta {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
table.meta th, table.meta td {
|
||||
text-align: left;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.totals {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 32px;
|
||||
}
|
||||
.totals table {
|
||||
border-collapse: collapse;
|
||||
min-width: 240px;
|
||||
}
|
||||
.totals td {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.totals td.label {
|
||||
color: rgba(148, 163, 184, 0.8);
|
||||
padding-right: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<img src="<?= htmlspecialchars($brand['logo']) ?>" alt="<?= htmlspecialchars($brand['company']) ?> logo">
|
||||
<div>
|
||||
<h1><?= htmlspecialchars($brand['company']) ?></h1>
|
||||
<p><?= htmlspecialchars($brand['domain']) ?></p>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<?= $content ?? '' ?>
|
||||
</main>
|
||||
<footer>
|
||||
<?= nl2br(htmlspecialchars($brand['email_signature'])) ?>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
97
resources/views/documents/offer.php
Normal file
97
resources/views/documents/offer.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
/** @var array $offer */
|
||||
$offer = $offer ?? [];
|
||||
$lineItems = $offer['items'] ?? [];
|
||||
$customer = $offer['customer'] ?? [];
|
||||
$offerNumber = $offer['number'] ?? '';
|
||||
$title = 'Tilbud #' . ($offerNumber !== '' ? $offerNumber : 'Udkast');
|
||||
ob_start();
|
||||
?>
|
||||
<h2 style="margin-top:0;">Tilbud <?= htmlspecialchars($offerNumber !== '' ? $offerNumber : 'Udkast') ?></h2>
|
||||
<p style="margin:0 0 16px; color:rgba(148,163,184,0.85);">
|
||||
Udstedt: <?= htmlspecialchars($offer['issued_at'] ?? date('Y-m-d')) ?> ·
|
||||
Gyldig til: <?= htmlspecialchars($offer['valid_until'] ?? date('Y-m-d', strtotime('+14 days'))) ?>
|
||||
</p>
|
||||
<table class="meta">
|
||||
<tr>
|
||||
<th style="width:180px;">Kunde</th>
|
||||
<td><?= htmlspecialchars($customer['name'] ?? 'Angiv kunde') ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Kontaktperson</th>
|
||||
<td><?= htmlspecialchars($customer['contact'] ?? $customer['email'] ?? 'Angiv kontakt') ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Projekt</th>
|
||||
<td><?= htmlspecialchars($offer['project'] ?? 'Angiv projekt') ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width:100%; border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="text-align:left; color:rgba(226,232,240,0.85);">
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25);">Beskrivelse</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:100px;">Antal</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:140px;">Enhedspris</th>
|
||||
<th style="padding:12px 0; border-bottom:1px solid rgba(148,163,184,0.25); width:140px;">Beløb</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($lineItems)): ?>
|
||||
<tr>
|
||||
<td colspan="4" style="padding:24px 0; color:rgba(148,163,184,0.8); text-align:center;">Tilføj linjeelementer for at færdiggøre tilbuddet.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($lineItems as $item): ?>
|
||||
<?php $qty = (float) ($item['quantity'] ?? 0); $price = (float) ($item['unit_price'] ?? 0); ?>
|
||||
<tr>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<strong><?= htmlspecialchars($item['name'] ?? 'Ydelse') ?></strong><br>
|
||||
<span style="color:rgba(148,163,184,0.75); font-size:0.9rem;">
|
||||
<?= nl2br(htmlspecialchars($item['description'] ?? 'Beskriv ydelsen.')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<?= number_format($qty, 2, ',', '.') ?>
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12);">
|
||||
<?= number_format($price, 2, ',', '.') ?> kr.
|
||||
</td>
|
||||
<td style="padding:16px 0; border-bottom:1px solid rgba(148,163,184,0.12); font-weight:600;">
|
||||
<?= number_format($qty * $price, 2, ',', '.') ?> kr.
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<?php
|
||||
$subtotal = array_reduce($lineItems, fn ($carry, $item) => $carry + (float) ($item['quantity'] ?? 0) * (float) ($item['unit_price'] ?? 0), 0.0);
|
||||
$vatRate = (float) ($offer['vat_rate'] ?? 25);
|
||||
$vatAmount = $subtotal * ($vatRate / 100);
|
||||
$total = $subtotal + $vatAmount;
|
||||
?>
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label">Subtotal</td>
|
||||
<td><?= number_format($subtotal, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Moms (<?= number_format($vatRate, 0) ?>%)</td>
|
||||
<td><?= number_format($vatAmount, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label" style="font-weight:600;">Total</td>
|
||||
<td style="font-weight:700;"><?= number_format($total, 2, ',', '.') ?> kr.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (!empty($offer['notes'])): ?>
|
||||
<section style="margin-top:32px;">
|
||||
<h3 style="margin-bottom:8px;">Bemærkninger</h3>
|
||||
<p style="color:rgba(148,163,184,0.85);"><?= nl2br(htmlspecialchars($offer['notes'])) ?></p>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include base_path('resources/views/documents/layout.php');
|
||||
123
resources/views/layout/app.php
Normal file
123
resources/views/layout/app.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars(config('app.name')) ?><?= isset($title) ? ' - ' . htmlspecialchars($title) : '' ?></title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/modern-normalize/2.0.0/modern-normalize.min.css" integrity="sha512-jY8yKAGgwyUr0vbJIUKGwEuITdSb9VjA36TObgGJE0E7E5Wdl66iRS0LlwM651S01qmPvvrLpzjAU6YewsGmmw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background-color: #0f172a;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #0f172a 0%, #111827 100%);
|
||||
}
|
||||
a { color: #38bdf8; }
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
nav a {
|
||||
margin-left: 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(56, 189, 248, 0.15);
|
||||
color: #38bdf8;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #38bdf8, #6366f1);
|
||||
color: #0f172a;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 25px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
.alert {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
color: #fecaca;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div style="display:flex; align-items:center; gap:1rem;">
|
||||
<img src="<?= htmlspecialchars(config('branding.logo_path')) ?>" alt="<?= htmlspecialchars(config('branding.company')) ?> logo" style="height:56px; width:auto; border-radius:12px; background:rgba(15,23,42,0.45); padding:0.35rem 0.5rem;" loading="lazy">
|
||||
<div>
|
||||
<h1 style="margin: 0; font-size: 1.75rem;"><?= htmlspecialchars(config('app.name')) ?></h1>
|
||||
<p style="margin: 0; color: rgba(148, 163, 184, 0.8);"><?= htmlspecialchars(config('branding.domain')) ?> • Unified customer, project, and issue management</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php $auth = \App\Services\AuthService::getInstance(); ?>
|
||||
<?php if ($auth->check()): ?>
|
||||
<nav>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/projects">Projects</a>
|
||||
<a href="/customers">Customers</a>
|
||||
<a href="/logout">Log out</a>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<?= $content ?? '' ?>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
12
routes/web.php
Normal file
12
routes/web.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Controllers\AuthController;
|
||||
use App\Controllers\DashboardController;
|
||||
use App\Core\Response;
|
||||
use App\Http\Middleware\AuthMiddleware;
|
||||
|
||||
$router->get('/login', fn() => (new AuthController())->showLogin());
|
||||
$router->post('/login', fn() => (new AuthController())->login());
|
||||
$router->get('/logout', fn() => (new AuthController())->logout(), [AuthMiddleware::class]);
|
||||
|
||||
$router->get('/', fn() => (new DashboardController())->index(), [AuthMiddleware::class]);
|
||||
Reference in New Issue
Block a user