Initial commit
This commit is contained in:
commit
87415548e8
61 changed files with 7103 additions and 0 deletions
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(docker compose:*)",
|
||||||
|
"Bash(docker-compose --version)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# Datenbank
|
||||||
|
DB_HOST=db
|
||||||
|
DB_NAME=steuerkram
|
||||||
|
DB_USER=steuerkram
|
||||||
|
DB_PASS=steuerkram
|
||||||
|
|
||||||
|
# HTTP Basic Auth
|
||||||
|
AUTH_USER=admin
|
||||||
|
AUTH_PASS=changeme
|
||||||
|
|
||||||
|
# App
|
||||||
|
APP_SECRET=change-this-to-a-random-string
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
vendor/
|
||||||
|
data/mariadb/
|
||||||
|
uploads/
|
||||||
|
backups/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
FROM php:8.3-apache
|
||||||
|
|
||||||
|
# System dependencies for mPDF
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libzip-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg-dev \
|
||||||
|
libfreetype6-dev \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
curl \
|
||||||
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
|
&& docker-php-ext-install pdo pdo_mysql zip gd \
|
||||||
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy composer files first for layer caching
|
||||||
|
COPY composer.json composer.lock* ./
|
||||||
|
RUN composer install --no-dev --no-interaction --optimize-autoloader 2>/dev/null || true
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Apache configuration
|
||||||
|
COPY apache/steuerkram.conf /etc/apache2/sites-available/000-default.conf
|
||||||
|
RUN a2enmod rewrite headers
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
RUN chown -R www-data:www-data /var/www/html \
|
||||||
|
&& chmod -R 755 /var/www/html/public/static/uploads \
|
||||||
|
&& chmod -R 755 /var/www/html/data
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
24
apache/steuerkram.conf
Normal file
24
apache/steuerkram.conf
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot /var/www/html/public
|
||||||
|
ServerName localhost
|
||||||
|
|
||||||
|
<Directory /var/www/html/public>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Deny access to src/ and migrations/
|
||||||
|
<Directory /var/www/html/src>
|
||||||
|
Require all denied
|
||||||
|
</Directory>
|
||||||
|
<Directory /var/www/html/migrations>
|
||||||
|
Require all denied
|
||||||
|
</Directory>
|
||||||
|
<Directory /var/www/html/data>
|
||||||
|
Require all denied
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||||
|
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||||
|
</VirtualHost>
|
||||||
17
composer.json
Normal file
17
composer.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "steuerkram/steuerkram",
|
||||||
|
"description": "Self-hosted German accounting and tax filing application",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.3",
|
||||||
|
"mpdf/mpdf": "^8.2"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Steuerkram\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true
|
||||||
|
}
|
||||||
|
}
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- ./public:/var/www/html/public
|
||||||
|
- ./src:/var/www/html/src
|
||||||
|
- ./migrations:/var/www/html/migrations
|
||||||
|
- uploads:/var/www/html/public/static/uploads
|
||||||
|
- ./data:/var/www/html/data
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_NAME: steuerkram
|
||||||
|
DB_USER: steuerkram
|
||||||
|
DB_PASS: steuerkram
|
||||||
|
AUTH_USER: admin
|
||||||
|
AUTH_PASS: changeme
|
||||||
|
APP_SECRET: change-this-in-production
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
environment:
|
||||||
|
MARIADB_ROOT_PASSWORD: rootpass
|
||||||
|
MARIADB_DATABASE: steuerkram
|
||||||
|
MARIADB_USER: steuerkram
|
||||||
|
MARIADB_PASSWORD: steuerkram
|
||||||
|
volumes:
|
||||||
|
- ./data/mariadb:/var/lib/mysql
|
||||||
|
- ./migrations:/docker-entrypoint-initdb.d
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads:
|
||||||
251
migrations/001_initial_schema.sql
Normal file
251
migrations/001_initial_schema.sql
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
-- Steuerkram - Initial Schema
|
||||||
|
-- GoBD-konform: Buchungen sind unveränderlich, vollständiges Audit-Log
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET time_zone = '+00:00';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Geschäftsjahre
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS fiscal_years (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year SMALLINT UNSIGNED NOT NULL UNIQUE,
|
||||||
|
start_date DATE NOT NULL,
|
||||||
|
end_date DATE NOT NULL,
|
||||||
|
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: Jahr gesperrt nach Abschluss',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Kontenrahmen (SKR03 und anpassbar)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
kontonummer VARCHAR(10) NOT NULL UNIQUE,
|
||||||
|
bezeichnung VARCHAR(255) NOT NULL,
|
||||||
|
kontenart ENUM('Aktiv','Passiv','Aufwand','Ertrag','Neutral') NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
steuercode VARCHAR(20) NULL COMMENT 'z.B. USt19, VSt19, USt7, VSt7',
|
||||||
|
ustva_kz VARCHAR(10) NULL COMMENT 'ELSTER Kennzahl z.B. 81, 35',
|
||||||
|
is_system TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = SKR03-Standard',
|
||||||
|
aktiv TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_kontonummer (kontonummer),
|
||||||
|
INDEX idx_kontenart (kontenart)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Firmendaten / Einstellungen (Singleton, id=1)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS company_settings (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
firma VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
inhaber VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
strasse VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
plz VARCHAR(10) NOT NULL DEFAULT '',
|
||||||
|
ort VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
telefon VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
website VARCHAR(255),
|
||||||
|
ust_id VARCHAR(20) COMMENT 'Umsatzsteuer-ID DE...',
|
||||||
|
steuernummer VARCHAR(30) COMMENT 'Steuernummer beim Finanzamt',
|
||||||
|
finanzamt VARCHAR(255),
|
||||||
|
iban VARCHAR(34),
|
||||||
|
bic VARCHAR(11),
|
||||||
|
bank_name VARCHAR(255),
|
||||||
|
logo_path VARCHAR(500) COMMENT 'Pfad zum Logo-PNG',
|
||||||
|
ustva_steuernummer VARCHAR(20) COMMENT 'ELSTER-Format: FFFBBBBBBUUUUP',
|
||||||
|
ustva_bundesland VARCHAR(2) COMMENT 'z.B. BY, NW, BE',
|
||||||
|
invoice_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'AR',
|
||||||
|
invoice_nr_next INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
invoice_nr_year SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
beleg_nr_prefix VARCHAR(10) NOT NULL DEFAULT 'BLG',
|
||||||
|
beleg_nr_next INT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
payment_days_default INT UNSIGNED NOT NULL DEFAULT 14,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO company_settings (id, firma, inhaber, strasse, plz, ort) VALUES (1, '', '', '', '', '');
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Kunden
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS customers (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
kundennummer VARCHAR(20) NOT NULL UNIQUE,
|
||||||
|
firma VARCHAR(255),
|
||||||
|
anrede VARCHAR(20),
|
||||||
|
vorname VARCHAR(100),
|
||||||
|
nachname VARCHAR(100) NOT NULL,
|
||||||
|
strasse VARCHAR(255) NOT NULL,
|
||||||
|
plz VARCHAR(10) NOT NULL,
|
||||||
|
ort VARCHAR(255) NOT NULL,
|
||||||
|
land VARCHAR(2) NOT NULL DEFAULT 'DE',
|
||||||
|
email VARCHAR(255),
|
||||||
|
telefon VARCHAR(50),
|
||||||
|
ust_id VARCHAR(20) COMMENT 'Umsatzsteuer-ID des Kunden (B2B)',
|
||||||
|
steuernummer VARCHAR(30),
|
||||||
|
payment_days INT UNSIGNED NOT NULL DEFAULT 14,
|
||||||
|
notes TEXT,
|
||||||
|
archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_kundennummer (kundennummer),
|
||||||
|
INDEX idx_archived (archived)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Belege (Dokumente / Quittungen)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS belege (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
beleg_nr VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
filename VARCHAR(500) NOT NULL COMMENT 'Originaldateiname',
|
||||||
|
storage_path VARCHAR(500) NOT NULL COMMENT 'Pfad unter public/static/uploads/',
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
file_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256 für GoBD-Integrität',
|
||||||
|
beleg_datum DATE NULL COMMENT 'Datum auf dem Dokument',
|
||||||
|
eingangs_datum DATE NOT NULL COMMENT 'Eingangsdatum',
|
||||||
|
rechnungs_nr VARCHAR(100) COMMENT 'Rechnungsnummer des Lieferanten',
|
||||||
|
betrag_brutto DECIMAL(12,2) COMMENT 'Bruttobetrag lt. Dokument',
|
||||||
|
belegtyp ENUM('Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges') NOT NULL DEFAULT 'Eingangsrechnung',
|
||||||
|
notes TEXT,
|
||||||
|
booked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = alle Buchungszeilen erfasst',
|
||||||
|
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: gesperrt mit Geschäftsjahr',
|
||||||
|
fiscal_year_id INT UNSIGNED NULL REFERENCES fiscal_years(id),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_beleg_datum (beleg_datum),
|
||||||
|
INDEX idx_fiscal_year (fiscal_year_id),
|
||||||
|
INDEX idx_booked (booked)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Buchungen (Journaleinträge, doppelte Buchführung)
|
||||||
|
-- GoBD: KEINE UPDATE/DELETE auf gesperrte Buchungen
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
buchungs_nr VARCHAR(30) NOT NULL UNIQUE,
|
||||||
|
beleg_id INT UNSIGNED NOT NULL,
|
||||||
|
buchungs_datum DATE NOT NULL,
|
||||||
|
soll_konto_id INT UNSIGNED NOT NULL,
|
||||||
|
haben_konto_id INT UNSIGNED NOT NULL,
|
||||||
|
betrag_netto DECIMAL(12,2) NOT NULL,
|
||||||
|
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT 'Steuersatz in % (0, 7, 19)',
|
||||||
|
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
betrag_brutto DECIMAL(12,2) NOT NULL,
|
||||||
|
buchungstext VARCHAR(500) NOT NULL,
|
||||||
|
is_reversal TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1 = Stornobuchung',
|
||||||
|
reversed_by INT UNSIGNED NULL COMMENT 'ID der Stornobuchung',
|
||||||
|
locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'GoBD: unveränderlich',
|
||||||
|
fiscal_year_id INT UNSIGNED NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_booking_beleg FOREIGN KEY (beleg_id) REFERENCES belege(id),
|
||||||
|
CONSTRAINT fk_booking_soll FOREIGN KEY (soll_konto_id) REFERENCES accounts(id),
|
||||||
|
CONSTRAINT fk_booking_haben FOREIGN KEY (haben_konto_id) REFERENCES accounts(id),
|
||||||
|
CONSTRAINT fk_booking_year FOREIGN KEY (fiscal_year_id) REFERENCES fiscal_years(id),
|
||||||
|
INDEX idx_buchungs_datum (buchungs_datum),
|
||||||
|
INDEX idx_fiscal_year (fiscal_year_id),
|
||||||
|
INDEX idx_soll_konto (soll_konto_id),
|
||||||
|
INDEX idx_haben_konto (haben_konto_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- GoBD-Trigger: Gesperrte Buchungen dürfen nicht geändert werden
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE TRIGGER bookings_before_update
|
||||||
|
BEFORE UPDATE ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
IF OLD.locked = 1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'GoBD-Verletzung: Gesperrte Buchung kann nicht geändert werden.';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
|
||||||
|
CREATE TRIGGER bookings_before_delete
|
||||||
|
BEFORE DELETE ON bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
IF OLD.locked = 1 THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'GoBD-Verletzung: Gesperrte Buchung kann nicht gelöscht werden.';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Ausgangsrechnungen
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
rechnungs_nr VARCHAR(30) NULL UNIQUE COMMENT 'NULL solange Entwurf',
|
||||||
|
customer_id INT UNSIGNED NOT NULL,
|
||||||
|
betreff VARCHAR(500) NOT NULL,
|
||||||
|
leistungszeitraum_von DATE NULL,
|
||||||
|
leistungszeitraum_bis DATE NULL,
|
||||||
|
rechnungs_datum DATE NOT NULL,
|
||||||
|
faellig_datum DATE NOT NULL,
|
||||||
|
status ENUM('Entwurf','Gesendet','Bezahlt','Storniert') NOT NULL DEFAULT 'Entwurf',
|
||||||
|
betrag_netto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
betrag_ust DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
betrag_brutto DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
||||||
|
beleg_id INT UNSIGNED NULL REFERENCES belege(id),
|
||||||
|
notes TEXT,
|
||||||
|
fiscal_year_id INT UNSIGNED NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_invoice_customer FOREIGN KEY (customer_id) REFERENCES customers(id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_rechnungs_datum (rechnungs_datum)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Rechnungspositionen
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
invoice_id INT UNSIGNED NOT NULL,
|
||||||
|
position SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||||
|
bezeichnung VARCHAR(500) NOT NULL,
|
||||||
|
menge DECIMAL(10,3) NOT NULL DEFAULT 1.000,
|
||||||
|
einheit VARCHAR(20) NOT NULL DEFAULT 'Stk.',
|
||||||
|
einzelpreis DECIMAL(12,2) NOT NULL,
|
||||||
|
ust_satz DECIMAL(5,2) NOT NULL DEFAULT 19.00,
|
||||||
|
betrag_netto DECIMAL(12,2) NOT NULL,
|
||||||
|
betrag_ust DECIMAL(12,2) NOT NULL,
|
||||||
|
betrag_brutto DECIMAL(12,2) NOT NULL,
|
||||||
|
CONSTRAINT fk_line_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_invoice_id (invoice_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- UStVA Einreichungen
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS ustva_submissions (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year SMALLINT UNSIGNED NOT NULL,
|
||||||
|
month TINYINT UNSIGNED NOT NULL COMMENT '1-12',
|
||||||
|
xml_content MEDIUMTEXT NOT NULL COMMENT 'Generiertes ELSTER-XML',
|
||||||
|
field_values JSON COMMENT 'Kennzahlen als JSON',
|
||||||
|
submitted_at DATETIME NULL COMMENT 'NULL = noch nicht eingereicht',
|
||||||
|
status ENUM('Erstellt','Eingereicht') NOT NULL DEFAULT 'Erstellt',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_year_month (year, month)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Audit-Log (GoBD-Pflicht)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
table_name VARCHAR(50) NOT NULL,
|
||||||
|
record_id INT UNSIGNED NOT NULL,
|
||||||
|
action ENUM('INSERT','UPDATE','DELETE','LOCK') NOT NULL,
|
||||||
|
old_values JSON,
|
||||||
|
new_values JSON,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
INDEX idx_table_record (table_name, record_id),
|
||||||
|
INDEX idx_timestamp (timestamp)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
123
migrations/002_seed_skr03.sql
Normal file
123
migrations/002_seed_skr03.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
-- SKR03 Kontenrahmen - Wichtigste Konten für EÜR / Freiberufler
|
||||||
|
-- Kontenarten: Aktiv, Passiv, Aufwand, Ertrag, Neutral
|
||||||
|
-- steuercode: USt19, USt7, VSt19, VSt7, NULL (kein Steuercode)
|
||||||
|
-- ustva_kz: ELSTER-Kennzahl für UStVA
|
||||||
|
|
||||||
|
INSERT INTO accounts (kontonummer, bezeichnung, kontenart, steuercode, ustva_kz, is_system) VALUES
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 0: Anlagevermögen
|
||||||
|
-- ============================================================
|
||||||
|
('0410', 'EDV-Software', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('0420', 'Büroeinrichtung', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('0430', 'Technische Anlagen und Maschinen', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('0480', 'Betriebs- und Geschäftsausstattung', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('0490', 'Geringwertige Wirtschaftsgüter', 'Aktiv', NULL, NULL, 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 1: Umlaufvermögen / Geldkonten
|
||||||
|
-- ============================================================
|
||||||
|
('1000', 'Kasse', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('1200', 'Bank', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('1361', 'Abziehbare Vorsteuer 19 %', 'Aktiv', 'VSt19', '66', 1),
|
||||||
|
('1371', 'Abziehbare Vorsteuer 7 %', 'Aktiv', 'VSt7', '66', 1),
|
||||||
|
('1400', 'Forderungen aus Lieferungen und Leistungen', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('1460', 'Forderungen aus Lieferungen und Leistungen (steuerfrei)', 'Aktiv', NULL, NULL, 1),
|
||||||
|
('1576', 'Abziehbare Vorsteuer 19 % (Eingangsrechnungen)', 'Aktiv', 'VSt19', '66', 1),
|
||||||
|
('1571', 'Abziehbare Vorsteuer 7 % (Eingangsrechnungen)', 'Aktiv', 'VSt7', '66', 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 1: Verbindlichkeiten (Passiv)
|
||||||
|
-- ============================================================
|
||||||
|
('1600', 'Verbindlichkeiten aus Lieferungen und Leistungen', 'Passiv', NULL, NULL, 1),
|
||||||
|
('1700', 'Sonstige Verbindlichkeiten', 'Passiv', NULL, NULL, 1),
|
||||||
|
('1741', 'Umsatzsteuer 19 %', 'Passiv', 'USt19', '83', 1),
|
||||||
|
('1742', 'Umsatzsteuer 7 %', 'Passiv', 'USt7', '84', 1),
|
||||||
|
('1771', 'Umsatzsteuer-Vorauszahlungen', 'Passiv', NULL, NULL, 1),
|
||||||
|
('1780', 'Umsatzsteuer-Vorauszahlungen lfd. Jahr', 'Passiv', NULL, NULL, 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 3: Wareneinkauf (Aufwand)
|
||||||
|
-- ============================================================
|
||||||
|
('3100', 'Wareneinkauf 19 % Vorsteuer', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('3110', 'Wareneinkauf 7 % Vorsteuer', 'Aufwand', 'VSt7', '66', 1),
|
||||||
|
('3120', 'Wareneinkauf ohne Vorsteuer', 'Aufwand', NULL, NULL, 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 4: Betriebliche Aufwendungen
|
||||||
|
-- ============================================================
|
||||||
|
('4100', 'Gehälter', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4110', 'Löhne', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4120', 'Gesetzliche soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4130', 'Freiwillige soziale Aufwendungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4140', 'Aufwendungen für Altersversorgung', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4200', 'Raumkosten (Miete)', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4210', 'Heizung', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4240', 'Gas, Strom, Wasser', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4250', 'Reinigung', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4260', 'Gebäudeversicherungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4300', 'Betriebliche Versicherungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4320', 'Beiträge', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4330', 'Berufsgenossenschaft', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4360', 'Beiträge zu Berufsverbänden', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4380', 'Verwaltungs-Gemeinkosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4390', 'Aufwand für Arbeitnehmer-Überlassung', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4400', 'PKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4410', 'LKW-Kosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4430', 'Kraftfahrzeugsteuer', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4460', 'Reisekosten Arbeitnehmer', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4500', 'Werbekosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4510', 'Kosten der Warenabgabe (Muster, Proben)', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4520', 'Repräsentationskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4530', 'Messekosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4540', 'Bewirtungskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4560', 'Buchführungskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4570', 'Jahresabschluss- und Prüfungskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4580', 'Beratungskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4600', 'Bürobedarf', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4610', 'Zeitschriften, Bücher', 'Aufwand', 'VSt7', '66', 1),
|
||||||
|
('4620', 'Portokosten', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4630', 'Telefon', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4640', 'Internetkosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4650', 'Büromaschinen (Wartung/Reparatur)', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4660', 'EDV-Kosten (Software, Lizenzen)', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4670', 'Betriebliche Fortbildungskosten', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4680', 'Fachliteratur', 'Aufwand', 'VSt7', '66', 1),
|
||||||
|
('4700', 'Abschreibungen auf Anlagevermögen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4710', 'Abschreibungen auf GWG', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4800', 'Bankgebühren', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4820', 'Zinsen und ähnliche Aufwendungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4830', 'Mahngebühren, Inkassokosten', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4900', 'Sonstige betriebliche Aufwendungen', 'Aufwand', 'VSt19', '66', 1),
|
||||||
|
('4920', 'Gewerbesteuer', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4930', 'Kraftfahrzeugsteuer (betrieblich)', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4950', 'Sonstige Steuern', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4960', 'Sonstige betriebliche Aufwendungen (ohne VSt)', 'Aufwand', NULL, NULL, 1),
|
||||||
|
('4970', 'Aufwendungen aus Verlust Forderungen', 'Aufwand', NULL, NULL, 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 8: Erträge
|
||||||
|
-- ============================================================
|
||||||
|
('8000', 'Erlöse aus Lieferungen und Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
|
||||||
|
('8100', 'Erlöse aus Lieferungen und Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
|
||||||
|
('8120', 'Erlöse aus Lieferungen und Leistungen (steuerfrei)', 'Ertrag', NULL, '35', 1),
|
||||||
|
('8125', 'Steuerfreie EU-Lieferungen', 'Ertrag', NULL, '41', 1),
|
||||||
|
('8130', 'Sonstige steuerfreie Umsätze', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8200', 'Erlöse aus sonstigen Leistungen 19 % USt', 'Ertrag', 'USt19', '81', 1),
|
||||||
|
('8300', 'Erlöse aus sonstigen Leistungen 7 % USt', 'Ertrag', 'USt7', '86', 1),
|
||||||
|
('8400', 'Erlöse aus Anlagenabgängen (steuerpflichtig)', 'Ertrag', 'USt19', '81', 1),
|
||||||
|
('8500', 'Zinsen und ähnliche Erträge', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8600', 'Erträge aus Beteiligungen', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8900', 'Sonstige betriebliche Erträge', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8910', 'Sonstige betriebliche Erträge (steuerfrei)', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8920', 'Erträge aus Auflösung Rückstellungen', 'Ertrag', NULL, NULL, 1),
|
||||||
|
('8940', 'Erträge aus Kursgewinnen', 'Ertrag', NULL, NULL, 1),
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- KLASSE 9: Eigenkapital / Privat (Neutral)
|
||||||
|
-- ============================================================
|
||||||
|
('9000', 'Eigenkapital', 'Neutral', NULL, NULL, 1),
|
||||||
|
('9100', 'Privatentnahmen', 'Neutral', NULL, NULL, 1),
|
||||||
|
('9200', 'Privateinlagen', 'Neutral', NULL, NULL, 1),
|
||||||
|
('9300', 'Gewinnvortrag', 'Neutral', NULL, NULL, 1),
|
||||||
|
('9400', 'Verlustvortrag', 'Neutral', NULL, NULL, 1);
|
||||||
6
migrations/003_test_mode.sql
Normal file
6
migrations/003_test_mode.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Migration 003: Test-Modus Einstellung
|
||||||
|
ALTER TABLE company_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS test_mode TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
COMMENT '1 = Testinstallation (Löschen erlaubt), 0 = Produktiv (GoBD-strikt)';
|
||||||
|
|
||||||
|
UPDATE company_settings SET test_mode = 1 WHERE id = 1;
|
||||||
10
public/.htaccess
Normal file
10
public/.htaccess
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
Options -Indexes
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Serve existing static files directly
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Route everything else through index.php
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
96
public/index.php
Normal file
96
public/index.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_DIR', dirname(__DIR__));
|
||||||
|
|
||||||
|
require BASE_DIR . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Steuerkram\Auth;
|
||||||
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\Router;
|
||||||
|
use Steuerkram\Controllers\AccountController;
|
||||||
|
use Steuerkram\Controllers\BelegController;
|
||||||
|
use Steuerkram\Controllers\BookingController;
|
||||||
|
use Steuerkram\Controllers\CustomerController;
|
||||||
|
use Steuerkram\Controllers\InvoiceController;
|
||||||
|
use Steuerkram\Controllers\UstvaController;
|
||||||
|
use Steuerkram\Controllers\EuerController;
|
||||||
|
use Steuerkram\Controllers\SettingsController;
|
||||||
|
|
||||||
|
Config::load();
|
||||||
|
Auth::require();
|
||||||
|
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
// ── Dashboard ────────────────────────────────────────────────
|
||||||
|
$router->get('/', fn($p) => (new AccountController())->dashboard($p));
|
||||||
|
$router->get('/dashboard', fn($p) => (new AccountController())->dashboard($p));
|
||||||
|
|
||||||
|
// ── Kontenrahmen ─────────────────────────────────────────────
|
||||||
|
$router->get('/accounts', fn($p) => (new AccountController())->index($p));
|
||||||
|
$router->get('/accounts/new', fn($p) => (new AccountController())->create($p));
|
||||||
|
$router->post('/accounts', fn($p) => (new AccountController())->store($p));
|
||||||
|
$router->get('/accounts/{id}/edit', fn($p) => (new AccountController())->edit($p));
|
||||||
|
$router->post('/accounts/{id}', fn($p) => (new AccountController())->update($p));
|
||||||
|
$router->post('/accounts/{id}/delete', fn($p) => (new AccountController())->destroy($p));
|
||||||
|
$router->get('/accounts/search', fn($p) => (new AccountController())->search($p));
|
||||||
|
|
||||||
|
// ── Belege ───────────────────────────────────────────────────
|
||||||
|
$router->get('/belege', fn($p) => (new BelegController())->index($p));
|
||||||
|
$router->get('/belege/upload', fn($p) => (new BelegController())->uploadForm($p));
|
||||||
|
$router->post('/belege/upload', fn($p) => (new BelegController())->upload($p));
|
||||||
|
$router->get('/belege/{id}', fn($p) => (new BelegController())->show($p));
|
||||||
|
$router->get('/belege/{id}/file', fn($p) => (new BelegController())->serveFile($p));
|
||||||
|
$router->post('/belege/{id}/delete', fn($p) => (new BelegController())->destroy($p));
|
||||||
|
$router->post('/belege/{id}/meta', fn($p) => (new BelegController())->updateMeta($p));
|
||||||
|
|
||||||
|
// ── Buchungen ────────────────────────────────────────────────
|
||||||
|
$router->get('/journal', fn($p) => (new BookingController())->journal($p));
|
||||||
|
$router->get('/journal/export', fn($p) => (new BookingController())->export($p));
|
||||||
|
$router->post('/belege/{beleg_id}/bookings', fn($p) => (new BookingController())->store($p));
|
||||||
|
$router->post('/bookings/{id}/reverse', fn($p) => (new BookingController())->reverse($p));
|
||||||
|
$router->post('/bookings/{id}/delete', fn($p) => (new BookingController())->destroy($p));
|
||||||
|
|
||||||
|
// ── Kunden ───────────────────────────────────────────────────
|
||||||
|
$router->get('/customers', fn($p) => (new CustomerController())->index($p));
|
||||||
|
$router->get('/customers/new', fn($p) => (new CustomerController())->create($p));
|
||||||
|
$router->post('/customers', fn($p) => (new CustomerController())->store($p));
|
||||||
|
$router->get('/customers/{id}', fn($p) => (new CustomerController())->show($p));
|
||||||
|
$router->get('/customers/{id}/edit', fn($p) => (new CustomerController())->edit($p));
|
||||||
|
$router->post('/customers/{id}', fn($p) => (new CustomerController())->update($p));
|
||||||
|
$router->post('/customers/{id}/archive', fn($p) => (new CustomerController())->archive($p));
|
||||||
|
|
||||||
|
// ── Rechnungen ───────────────────────────────────────────────
|
||||||
|
$router->get('/invoices', fn($p) => (new InvoiceController())->index($p));
|
||||||
|
$router->get('/invoices/new', fn($p) => (new InvoiceController())->create($p));
|
||||||
|
$router->post('/invoices', fn($p) => (new InvoiceController())->store($p));
|
||||||
|
$router->get('/invoices/{id}', fn($p) => (new InvoiceController())->show($p));
|
||||||
|
$router->get('/invoices/{id}/edit', fn($p) => (new InvoiceController())->edit($p));
|
||||||
|
$router->post('/invoices/{id}', fn($p) => (new InvoiceController())->update($p));
|
||||||
|
$router->post('/invoices/{id}/finalize', fn($p) => (new InvoiceController())->finalize($p));
|
||||||
|
$router->get('/invoices/{id}/pdf', fn($p) => (new InvoiceController())->pdf($p));
|
||||||
|
$router->post('/invoices/{id}/book', fn($p) => (new InvoiceController())->book($p));
|
||||||
|
$router->post('/invoices/{id}/mark-paid', fn($p) => (new InvoiceController())->markPaid($p));
|
||||||
|
$router->post('/invoices/{id}/cancel', fn($p) => (new InvoiceController())->cancel($p));
|
||||||
|
$router->post('/invoices/{id}/lines', fn($p) => (new InvoiceController())->addLine($p));
|
||||||
|
$router->post('/invoices/{id}/lines/{line_id}/delete', fn($p) => (new InvoiceController())->deleteLine($p));
|
||||||
|
|
||||||
|
// ── UStVA ────────────────────────────────────────────────────
|
||||||
|
$router->get('/tax/ustva', fn($p) => (new UstvaController())->index($p));
|
||||||
|
$router->get('/tax/ustva/{year}/{month}', fn($p) => (new UstvaController())->show($p));
|
||||||
|
$router->post('/tax/ustva/{year}/{month}/save', fn($p) => (new UstvaController())->save($p));
|
||||||
|
$router->get('/tax/ustva/{year}/{month}/xml', fn($p) => (new UstvaController())->downloadXml($p));
|
||||||
|
|
||||||
|
// ── EÜR ─────────────────────────────────────────────────────
|
||||||
|
$router->get('/tax/euer', fn($p) => (new EuerController())->index($p));
|
||||||
|
$router->get('/tax/euer/{year}', fn($p) => (new EuerController())->show($p));
|
||||||
|
$router->get('/tax/euer/{year}/pdf', fn($p) => (new EuerController())->pdf($p));
|
||||||
|
|
||||||
|
// ── Einstellungen ────────────────────────────────────────────
|
||||||
|
$router->get('/settings', fn($p) => (new SettingsController())->show($p));
|
||||||
|
$router->post('/settings', fn($p) => (new SettingsController())->update($p));
|
||||||
|
$router->post('/settings/logo', fn($p) => (new SettingsController())->uploadLogo($p));
|
||||||
|
$router->post('/settings/logo/delete', fn($p) => (new SettingsController())->deleteLogo($p));
|
||||||
|
|
||||||
|
$router->dispatch();
|
||||||
453
public/static/css/app.css
Normal file
453
public/static/css/app.css
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
/* ============================================================
|
||||||
|
Steuerkram – Haupt-Stylesheet
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg: #f4f6f9;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
--color-border: #d1d9e0;
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-h: #1d4ed8;
|
||||||
|
--color-danger: #dc2626;
|
||||||
|
--color-danger-h: #b91c1c;
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-warning: #d97706;
|
||||||
|
--color-text: #1e293b;
|
||||||
|
--color-muted: #64748b;
|
||||||
|
--color-nav-bg: #1e293b;
|
||||||
|
--color-nav-text: #cbd5e1;
|
||||||
|
--color-nav-hover: #334155;
|
||||||
|
--color-nav-active:#2563eb;
|
||||||
|
|
||||||
|
--radius: 6px;
|
||||||
|
--shadow-sm: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
--shadow: 0 2px 8px rgba(0,0,0,.10);
|
||||||
|
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
--nav-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ─────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 15px; }
|
||||||
|
body { font-family: var(--font); background: var(--color-bg); color: var(--color-text); }
|
||||||
|
a { color: var(--color-primary); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
|
||||||
|
/* ── Layout ─────────────────────────────────────────────────── */
|
||||||
|
.app-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--nav-width) 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sidebar Navigation ─────────────────────────────────────── */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--color-nav-bg);
|
||||||
|
color: var(--color-nav-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand {
|
||||||
|
padding: 1.25rem 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-brand span { color: var(--color-nav-active); }
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
padding: .5rem 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-label {
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
color: #475569;
|
||||||
|
padding: .5rem 1rem .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .55rem 1rem;
|
||||||
|
color: var(--color-nav-text);
|
||||||
|
font-size: .875rem;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a:hover {
|
||||||
|
background: var(--color-nav-hover);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a.active {
|
||||||
|
background: rgba(37,99,235,.15);
|
||||||
|
color: #93c5fd;
|
||||||
|
border-left-color: var(--color-nav-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar nav a svg {
|
||||||
|
width: 16px; height: 16px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main content ───────────────────────────────────────────── */
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: .75rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flash Messages ─────────────────────────────────────────── */
|
||||||
|
.flash-list { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
|
||||||
|
.flash-error { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
|
||||||
|
.flash-warning { background: #fef9c3; color: #854d0e; border: 1px solid #fef08a; }
|
||||||
|
.flash-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
|
||||||
|
|
||||||
|
/* ── Cards ──────────────────────────────────────────────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title { font-size: 1rem; font-weight: 600; }
|
||||||
|
|
||||||
|
.card-body { padding: 1.25rem; }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .45rem .9rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, border-color .15s, opacity .15s;
|
||||||
|
text-decoration: none !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--color-primary); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--color-primary-h); }
|
||||||
|
|
||||||
|
.btn-secondary { background: var(--color-surface); color: var(--color-text); border-color: var(--color-border); }
|
||||||
|
.btn-secondary:hover { background: var(--color-bg); }
|
||||||
|
|
||||||
|
.btn-danger { background: var(--color-danger); color: #fff; }
|
||||||
|
.btn-danger:hover { background: var(--color-danger-h); }
|
||||||
|
|
||||||
|
.btn-success { background: var(--color-success); color: #fff; }
|
||||||
|
.btn-success:hover { background: #15803d; }
|
||||||
|
|
||||||
|
.btn-sm { padding: .3rem .65rem; font-size: .8rem; }
|
||||||
|
.btn-lg { padding: .65rem 1.25rem; font-size: 1rem; }
|
||||||
|
|
||||||
|
/* ── Forms ───────────────────────────────────────────────────── */
|
||||||
|
.form-group { margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: .3rem;
|
||||||
|
color: var(--color-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="tel"],
|
||||||
|
input[type="url"],
|
||||||
|
input[type="password"],
|
||||||
|
input[type="search"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .875rem;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37,99,235,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error { border-color: var(--color-danger) !important; }
|
||||||
|
.error-text { font-size: .8rem; color: var(--color-danger); margin-top: .25rem; }
|
||||||
|
|
||||||
|
textarea { min-height: 80px; resize: vertical; }
|
||||||
|
|
||||||
|
.form-grid { display: grid; gap: 1rem; }
|
||||||
|
.form-grid-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.form-grid-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.form-grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint { font-size: .78rem; color: var(--color-muted); margin-top: .2rem; }
|
||||||
|
|
||||||
|
/* ── Tables ──────────────────────────────────────────────────── */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
color: var(--color-muted);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr { border-bottom: 1px solid var(--color-border); }
|
||||||
|
tbody tr:last-child { border-bottom: none; }
|
||||||
|
tbody tr:hover { background: #f8fafc; }
|
||||||
|
|
||||||
|
td { padding: .65rem 1rem; vertical-align: middle; }
|
||||||
|
|
||||||
|
.td-right { text-align: right; }
|
||||||
|
.td-mono { font-family: var(--font-mono); font-size: .82rem; }
|
||||||
|
.td-muted { color: var(--color-muted); font-size: .82rem; }
|
||||||
|
|
||||||
|
/* ── Badges ──────────────────────────────────────────────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: .15rem .6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: .72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green { background: #dcfce7; color: #166534; }
|
||||||
|
.badge-red { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-yellow { background: #fef9c3; color: #854d0e; }
|
||||||
|
.badge-blue { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-gray { background: #f1f5f9; color: #475569; }
|
||||||
|
.badge-purple { background: #ede9fe; color: #6d28d9; }
|
||||||
|
|
||||||
|
/* ── Toolbar / Filter bar ────────────────────────────────────── */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||||
|
.toolbar-right { margin-left: auto; display: flex; gap: .5rem; }
|
||||||
|
|
||||||
|
/* ── Stats / Dashboard Cards ─────────────────────────────────── */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label { font-size: .78rem; color: var(--color-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: .4rem; }
|
||||||
|
.stat-value { font-size: 1.6rem; font-weight: 700; }
|
||||||
|
.stat-meta { font-size: .78rem; color: var(--color-muted); margin-top: .25rem; }
|
||||||
|
|
||||||
|
/* ── Beleg Detail (Split-Pane) ───────────────────────────────── */
|
||||||
|
.beleg-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 420px;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beleg-preview {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beleg-preview iframe,
|
||||||
|
.beleg-preview img {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 700px;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Invoice preview (DIN 5008) ──────────────────────────────── */
|
||||||
|
.invoice-preview {
|
||||||
|
background: #fff;
|
||||||
|
padding: 2cm 2cm 2.5cm;
|
||||||
|
max-width: 21cm;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 11pt;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modals ──────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
max-width: 500px;
|
||||||
|
width: 95%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||||
|
.modal-actions { display: flex; gap: .75rem; justify-content: flex-end; margin-top: 1.25rem; }
|
||||||
|
|
||||||
|
/* ── Pagination ──────────────────────────────────────────────── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a, .pagination span {
|
||||||
|
padding: .35rem .7rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .82rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a:hover { background: var(--color-bg); text-decoration: none; }
|
||||||
|
.pagination .active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||||
|
|
||||||
|
/* ── Utilities ───────────────────────────────────────────────── */
|
||||||
|
.text-muted { color: var(--color-muted); }
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-sm { font-size: .82rem; }
|
||||||
|
.text-lg { font-size: 1.1rem; }
|
||||||
|
.font-bold { font-weight: 700; }
|
||||||
|
.font-mono { font-family: var(--font-mono); }
|
||||||
|
.mt-1 { margin-top: .5rem; }
|
||||||
|
.mt-2 { margin-top: 1rem; }
|
||||||
|
.mt-3 { margin-top: 1.5rem; }
|
||||||
|
.mb-1 { margin-bottom: .5rem; }
|
||||||
|
.mb-2 { margin-bottom: 1rem; }
|
||||||
|
.mb-3 { margin-bottom: 1.5rem; }
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.gap-1 { gap: .5rem; }
|
||||||
|
.gap-2 { gap: 1rem; }
|
||||||
|
.align-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.w-full { width: 100%; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
|
||||||
|
/* ── Print ───────────────────────────────────────────────────── */
|
||||||
|
@media print {
|
||||||
|
.sidebar, .topbar, .btn, .toolbar, .pagination { display: none !important; }
|
||||||
|
.main-content { grid-column: 1 / -1; }
|
||||||
|
.page-body { padding: 0; }
|
||||||
|
}
|
||||||
267
public/static/js/app.js
Normal file
267
public/static/js/app.js
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
/**
|
||||||
|
* Steuerkram – App JavaScript
|
||||||
|
* Kein Framework. Nur native DOM-APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Kontenart-Badge neben Label anzeigen ─────────────────────
|
||||||
|
function showKontoArt(selectId, badgeId, sel) {
|
||||||
|
const badge = document.getElementById(badgeId);
|
||||||
|
if (!badge) return;
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const art = opt?.dataset?.art || '';
|
||||||
|
const classes = {
|
||||||
|
'Aufwand': 'badge-red',
|
||||||
|
'Ertrag': 'badge-green',
|
||||||
|
'Aktiv': 'badge-blue',
|
||||||
|
'Passiv': 'badge-yellow',
|
||||||
|
'Neutral': 'badge-gray',
|
||||||
|
};
|
||||||
|
badge.textContent = art;
|
||||||
|
badge.className = 'badge ' + (classes[art] || 'badge-gray');
|
||||||
|
badge.style.display = art ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Konto-Filter (Select-Filterung per Sucheingabe) ───────────
|
||||||
|
document.addEventListener('input', (e) => {
|
||||||
|
if (!e.target.classList.contains('konto-filter')) return;
|
||||||
|
const q = e.target.value.toLowerCase();
|
||||||
|
const select = document.getElementById(e.target.dataset.target);
|
||||||
|
if (!select) return;
|
||||||
|
for (const opt of select.options) {
|
||||||
|
if (opt.value === '') { opt.hidden = false; continue; }
|
||||||
|
opt.hidden = !opt.dataset.search.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
// Auto-select first visible match
|
||||||
|
if (q.length > 0) {
|
||||||
|
for (const opt of select.options) {
|
||||||
|
if (!opt.hidden && opt.value !== '') { select.value = opt.value; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Modals ────────────────────────────────────────────────────
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
// Open modal
|
||||||
|
const openBtn = e.target.closest('[data-modal-open]');
|
||||||
|
if (openBtn) {
|
||||||
|
const id = openBtn.dataset.modalOpen;
|
||||||
|
document.getElementById(id)?.classList.add('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal via backdrop click or close button
|
||||||
|
const closeBtn = e.target.closest('[data-modal-close]');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.closest('.modal-overlay')?.classList.remove('open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains('modal-overlay')) {
|
||||||
|
e.target.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on Escape key
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Confirm Delete ────────────────────────────────────────────
|
||||||
|
document.addEventListener('submit', (e) => {
|
||||||
|
const form = e.target;
|
||||||
|
const msg = form.dataset.confirm;
|
||||||
|
if (msg && !confirm(msg)) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rechnungszeilen – Live-Kalkulation ────────────────────────
|
||||||
|
function calcInvoiceLine(row) {
|
||||||
|
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
|
||||||
|
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
|
||||||
|
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
|
||||||
|
|
||||||
|
const netto = menge * preis;
|
||||||
|
const ust = netto * ustSatz / 100;
|
||||||
|
const brutto = netto + ust;
|
||||||
|
|
||||||
|
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const nettoEl = row.querySelector('.line-netto');
|
||||||
|
const bruttoEl = row.querySelector('.line-brutto');
|
||||||
|
if (nettoEl) nettoEl.textContent = fmt(netto) + ' €';
|
||||||
|
if (bruttoEl) bruttoEl.textContent = fmt(brutto) + ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcInvoiceTotals() {
|
||||||
|
let totalNetto = 0;
|
||||||
|
let totalUst = 0;
|
||||||
|
let totalBrutto = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll('.invoice-line-row').forEach(row => {
|
||||||
|
const menge = parseFloat(row.querySelector('[name$="[menge]"]')?.value) || 0;
|
||||||
|
const preis = parseFloat(row.querySelector('[name$="[einzelpreis]"]')?.value) || 0;
|
||||||
|
const ustSatz = parseFloat(row.querySelector('[name$="[ust_satz]"]')?.value) || 0;
|
||||||
|
const netto = menge * preis;
|
||||||
|
const ust = netto * ustSatz / 100;
|
||||||
|
totalNetto += netto;
|
||||||
|
totalUst += ust;
|
||||||
|
totalBrutto += netto + ust;
|
||||||
|
calcInvoiceLine(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmt = (v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €';
|
||||||
|
|
||||||
|
const elNetto = document.getElementById('total-netto');
|
||||||
|
const elUst = document.getElementById('total-ust');
|
||||||
|
const elBrutto = document.getElementById('total-brutto');
|
||||||
|
if (elNetto) elNetto.textContent = fmt(totalNetto);
|
||||||
|
if (elUst) elUst.textContent = fmt(totalUst);
|
||||||
|
if (elBrutto) elBrutto.textContent = fmt(totalBrutto);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-Update on input change in invoice form
|
||||||
|
document.addEventListener('input', (e) => {
|
||||||
|
if (e.target.closest('.invoice-line-row')) {
|
||||||
|
calcInvoiceTotals();
|
||||||
|
}
|
||||||
|
if (e.target.closest('.invoice-line-row') && e.target.matches('[name$="[ust_satz]"]')) {
|
||||||
|
calcInvoiceTotals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rechnungszeile hinzufügen ─────────────────────────────────
|
||||||
|
let lineCounter = 0;
|
||||||
|
|
||||||
|
function addInvoiceLine() {
|
||||||
|
lineCounter++;
|
||||||
|
const tbody = document.getElementById('invoice-lines-body');
|
||||||
|
if (!tbody) return;
|
||||||
|
|
||||||
|
const idx = tbody.querySelectorAll('.invoice-line-row').length;
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'invoice-line-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<td><span class="line-pos text-muted">${idx + 1}</span></td>
|
||||||
|
<td><input type="text" name="lines[${idx}][bezeichnung]" required placeholder="Leistungsbeschreibung" style="min-width:200px"></td>
|
||||||
|
<td><input type="number" name="lines[${idx}][menge]" value="1" min="0.001" step="0.001" style="width:80px" required></td>
|
||||||
|
<td>
|
||||||
|
<select name="lines[${idx}][einheit]" style="width:80px">
|
||||||
|
<option value="Stk.">Stk.</option>
|
||||||
|
<option value="h">h</option>
|
||||||
|
<option value="Tage">Tage</option>
|
||||||
|
<option value="Monat">Monat</option>
|
||||||
|
<option value="Pauschale">Pauschale</option>
|
||||||
|
<option value="km">km</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="lines[${idx}][ust_satz]" style="width:80px">
|
||||||
|
<option value="19">19 %</option>
|
||||||
|
<option value="7">7 %</option>
|
||||||
|
<option value="0">0 %</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" name="lines[${idx}][einzelpreis]" value="0.00" min="0" step="0.01" style="width:110px" required></td>
|
||||||
|
<td class="td-right line-netto text-muted">0,00 €</td>
|
||||||
|
<td class="td-right line-brutto font-bold">0,00 €</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button>
|
||||||
|
</td>`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
calcInvoiceTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInvoiceLine(btn) {
|
||||||
|
btn.closest('.invoice-line-row').remove();
|
||||||
|
// Renumber positions
|
||||||
|
document.querySelectorAll('.invoice-line-row').forEach((row, i) => {
|
||||||
|
const posEl = row.querySelector('.line-pos');
|
||||||
|
if (posEl) posEl.textContent = i + 1;
|
||||||
|
// Update indices in name attributes
|
||||||
|
row.querySelectorAll('[name]').forEach(el => {
|
||||||
|
el.name = el.name.replace(/lines\[\d+\]/, `lines[${i}]`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
calcInvoiceTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init totals on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if (document.getElementById('invoice-lines-body')) {
|
||||||
|
calcInvoiceTotals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Konto-Suche (Typeahead) ───────────────────────────────────
|
||||||
|
function setupAccountSearch(inputId, hiddenId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const hidden = document.getElementById(hiddenId);
|
||||||
|
if (!input || !hidden) return;
|
||||||
|
|
||||||
|
let dropdown = null;
|
||||||
|
let debounce = null;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounce);
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 1) { closeDropdown(); return; }
|
||||||
|
|
||||||
|
debounce = setTimeout(async () => {
|
||||||
|
const res = await fetch(`/accounts/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
renderDropdown(data);
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderDropdown(items) {
|
||||||
|
closeDropdown();
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
dropdown = document.createElement('ul');
|
||||||
|
dropdown.className = 'account-dropdown';
|
||||||
|
dropdown.style.cssText = `
|
||||||
|
position:absolute; z-index:50; background:#fff; border:1px solid var(--color-border);
|
||||||
|
border-radius:var(--radius); width:100%; max-height:220px; overflow-y:auto;
|
||||||
|
list-style:none; padding:0; margin:0; box-shadow:var(--shadow);
|
||||||
|
`;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.style.cssText = 'padding:.5rem .75rem; cursor:pointer; font-size:.875rem; border-bottom:1px solid var(--color-border)';
|
||||||
|
li.innerHTML = `<strong>${item.kontonummer}</strong> – ${item.bezeichnung} <small style="color:var(--color-muted)">(${item.kontenart})</small>`;
|
||||||
|
li.addEventListener('mousedown', () => {
|
||||||
|
input.value = `${item.kontonummer} – ${item.bezeichnung}`;
|
||||||
|
hidden.value = item.id;
|
||||||
|
closeDropdown();
|
||||||
|
});
|
||||||
|
dropdown.appendChild(li);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = input.closest('.account-search-wrap') || input.parentElement;
|
||||||
|
wrapper.style.position = 'relative';
|
||||||
|
wrapper.appendChild(dropdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
dropdown?.remove();
|
||||||
|
dropdown = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('blur', () => setTimeout(closeDropdown, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flash Message Auto-dismiss ────────────────────────────────
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.flash').forEach(el => {
|
||||||
|
setTimeout(() => {
|
||||||
|
el.style.transition = 'opacity .4s';
|
||||||
|
el.style.opacity = '0';
|
||||||
|
setTimeout(() => el.remove(), 400);
|
||||||
|
}, 4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
scripts/backup.sh
Executable file
32
scripts/backup.sh
Executable file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Steuerkram – Backup-Script
|
||||||
|
# GoBD: Datensicherung muss regelmäßig erfolgen.
|
||||||
|
# Usage: ./scripts/backup.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="./backups"
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
echo "Erstelle Backup $TIMESTAMP..."
|
||||||
|
|
||||||
|
# MariaDB Dump (aus dem Container)
|
||||||
|
docker compose exec -T db mysqldump \
|
||||||
|
--user=steuerkram \
|
||||||
|
--password=steuerkram \
|
||||||
|
--single-transaction \
|
||||||
|
--routines \
|
||||||
|
--triggers \
|
||||||
|
steuerkram > "$BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
|
||||||
|
|
||||||
|
# Uploads sichern
|
||||||
|
tar -czf "$BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz" ./uploads/
|
||||||
|
|
||||||
|
echo "Backup abgeschlossen:"
|
||||||
|
echo " DB: $BACKUP_DIR/steuerkram_db_$TIMESTAMP.sql"
|
||||||
|
echo " Uploads: $BACKUP_DIR/steuerkram_uploads_$TIMESTAMP.tar.gz"
|
||||||
|
|
||||||
|
# Alte Backups löschen (älter als 90 Tage)
|
||||||
|
find "$BACKUP_DIR" -name "steuerkram_*" -mtime +90 -delete
|
||||||
|
echo "Alte Backups bereinigt (>90 Tage)."
|
||||||
25
src/Auth.php
Normal file
25
src/Auth.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
class Auth
|
||||||
|
{
|
||||||
|
public static function require(): void
|
||||||
|
{
|
||||||
|
$user = Config::get('auth_user');
|
||||||
|
$pass = Config::get('auth_pass');
|
||||||
|
|
||||||
|
if (
|
||||||
|
!isset($_SERVER['PHP_AUTH_USER']) ||
|
||||||
|
$_SERVER['PHP_AUTH_USER'] !== $user ||
|
||||||
|
$_SERVER['PHP_AUTH_PW'] !== $pass
|
||||||
|
) {
|
||||||
|
header('WWW-Authenticate: Basic realm="Steuerkram"');
|
||||||
|
header('HTTP/1.1 401 Unauthorized');
|
||||||
|
echo '<!DOCTYPE html><html><body><h1>401 Unauthorized</h1></body></html>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/Config.php
Normal file
30
src/Config.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
class Config
|
||||||
|
{
|
||||||
|
private static array $data = [];
|
||||||
|
|
||||||
|
public static function load(): void
|
||||||
|
{
|
||||||
|
self::$data = [
|
||||||
|
'db_host' => $_ENV['DB_HOST'] ?? getenv('DB_HOST') ?: 'db',
|
||||||
|
'db_name' => $_ENV['DB_NAME'] ?? getenv('DB_NAME') ?: 'steuerkram',
|
||||||
|
'db_user' => $_ENV['DB_USER'] ?? getenv('DB_USER') ?: 'steuerkram',
|
||||||
|
'db_pass' => $_ENV['DB_PASS'] ?? getenv('DB_PASS') ?: 'steuerkram',
|
||||||
|
'auth_user' => $_ENV['AUTH_USER'] ?? getenv('AUTH_USER') ?: 'admin',
|
||||||
|
'auth_pass' => $_ENV['AUTH_PASS'] ?? getenv('AUTH_PASS') ?: 'changeme',
|
||||||
|
'app_secret' => $_ENV['APP_SECRET'] ?? getenv('APP_SECRET') ?: 'dev-secret',
|
||||||
|
'upload_dir' => dirname(__DIR__) . '/public/static/uploads',
|
||||||
|
'base_url' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return self::$data[$key] ?? $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/Controllers/AccountController.php
Normal file
228
src/Controllers/AccountController.php
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class AccountController
|
||||||
|
{
|
||||||
|
public function dashboard(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)date('Y');
|
||||||
|
|
||||||
|
// Quick stats
|
||||||
|
$stats = [];
|
||||||
|
|
||||||
|
// Umsatz (Ertrag-Konten)
|
||||||
|
$umsatz = \Steuerkram\Database::fetchOne(
|
||||||
|
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts a ON bk.haben_konto_id = a.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE a.kontenart = \'Ertrag\' AND fy.year = ?',
|
||||||
|
[$year]
|
||||||
|
);
|
||||||
|
$stats['umsatz'] = (float)($umsatz['total'] ?? 0);
|
||||||
|
|
||||||
|
// Ausgaben (Aufwand-Konten)
|
||||||
|
$ausgaben = \Steuerkram\Database::fetchOne(
|
||||||
|
'SELECT COALESCE(SUM(bk.betrag_netto),0) as total
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts a ON bk.soll_konto_id = a.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE a.kontenart = \'Aufwand\' AND fy.year = ?',
|
||||||
|
[$year]
|
||||||
|
);
|
||||||
|
$stats['ausgaben'] = (float)($ausgaben['total'] ?? 0);
|
||||||
|
$stats['gewinn'] = $stats['umsatz'] - $stats['ausgaben'];
|
||||||
|
$stats['year'] = $year;
|
||||||
|
|
||||||
|
// Offene Rechnungen
|
||||||
|
$offen = \Steuerkram\Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt, COALESCE(SUM(betrag_brutto),0) as total
|
||||||
|
FROM invoices WHERE status=\'Gesendet\''
|
||||||
|
);
|
||||||
|
$stats['offene_rechnungen'] = (int)($offen['cnt'] ?? 0);
|
||||||
|
$stats['offene_rechnungen_sum'] = (float)($offen['total'] ?? 0);
|
||||||
|
|
||||||
|
// Ungebuchte Belege
|
||||||
|
$ungebucht = \Steuerkram\Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt FROM belege WHERE booked=0'
|
||||||
|
);
|
||||||
|
$stats['ungebuchte_belege'] = (int)($ungebucht['cnt'] ?? 0);
|
||||||
|
|
||||||
|
// Letzte Buchungen
|
||||||
|
$recentBookings = \Steuerkram\Database::fetchAll(
|
||||||
|
'SELECT bk.*, s.kontonummer as soll_nr, s.bezeichnung as soll_name,
|
||||||
|
h.kontonummer as haben_nr, h.bezeichnung as haben_name,
|
||||||
|
bl.beleg_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts s ON bk.soll_konto_id = s.id
|
||||||
|
JOIN accounts h ON bk.haben_konto_id = h.id
|
||||||
|
JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
ORDER BY bk.created_at DESC LIMIT 10'
|
||||||
|
);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Dashboard',
|
||||||
|
'content' => $this->renderView('dashboard', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'recentBookings' => $recentBookings,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
$accounts = Account::all($filter);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kontenrahmen (SKR03)',
|
||||||
|
'content' => $this->renderView('accounts/list', [
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'filter' => $filter,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/accounts/new" class="btn btn-primary">+ Konto anlegen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $params): void
|
||||||
|
{
|
||||||
|
$back = $_GET['from'] === 'settings' ? '/settings' : '/accounts';
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Konto anlegen',
|
||||||
|
'content' => $this->renderView('accounts/form', [
|
||||||
|
'account' => [],
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/accounts',
|
||||||
|
'back' => $back,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(array $params): void
|
||||||
|
{
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
|
||||||
|
if (Account::isKontonummerTaken($data['kontonummer'] ?? '')) {
|
||||||
|
$errors['kontonummer'] = 'Diese Kontonummer ist bereits vergeben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$back = $_POST['_back'] ?? '/accounts';
|
||||||
|
if ($errors) {
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Konto anlegen',
|
||||||
|
'content' => $this->renderView('accounts/form', [
|
||||||
|
'account' => $data,
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/accounts',
|
||||||
|
'back' => $back,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Account::create($data);
|
||||||
|
$back = $_POST['_back'] ?? '/accounts';
|
||||||
|
View::redirect($back, 'Konto erfolgreich angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(array $params): void
|
||||||
|
{
|
||||||
|
$account = Account::find((int)$params['id']);
|
||||||
|
if (!$account) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$back = ($_GET['from'] ?? '') === 'settings' ? '/settings' : '/accounts';
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Konto bearbeiten',
|
||||||
|
'content' => $this->renderView('accounts/form', [
|
||||||
|
'account' => $account,
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/accounts/' . $account['id'],
|
||||||
|
'back' => $back,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $params): void
|
||||||
|
{
|
||||||
|
$data = $_POST;
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$account = Account::find($id);
|
||||||
|
if (!$account) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
$back = $_POST['_back'] ?? '/accounts';
|
||||||
|
|
||||||
|
if (Account::isKontonummerTaken($data['kontonummer'] ?? '', $id)) {
|
||||||
|
$errors['kontonummer'] = 'Diese Kontonummer ist bereits vergeben.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Konto bearbeiten',
|
||||||
|
'content' => $this->renderView('accounts/form', [
|
||||||
|
'account' => array_merge($account, $data),
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/accounts/' . $id,
|
||||||
|
'back' => $back,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Account::update($id, $data);
|
||||||
|
$back = $_POST['_back'] ?? '/accounts';
|
||||||
|
View::redirect($back, 'Konto aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$back = $_POST['_back'] ?? '/accounts';
|
||||||
|
if (!Account::delete($id)) {
|
||||||
|
View::redirect($back, 'Konto kann nicht gelöscht werden (wird in Buchungen verwendet).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
View::redirect($back, 'Konto gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(array $params): void
|
||||||
|
{
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
$results = $q ? Account::search($q) : [];
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($results, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
if (empty($data['kontonummer'])) $errors['kontonummer'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['bezeichnung'])) $errors['bezeichnung'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['kontenart'])) $errors['kontenart'] = 'Pflichtfeld';
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
202
src/Controllers/BelegController.php
Normal file
202
src/Controllers/BelegController.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Config;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class BelegController
|
||||||
|
{
|
||||||
|
private const ALLOWED_MIME = [
|
||||||
|
'application/pdf' => 'pdf',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/tiff' => 'tif',
|
||||||
|
'application/xml' => 'xml',
|
||||||
|
'text/xml' => 'xml',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$result = Beleg::all($filter, $page);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Belege',
|
||||||
|
'content' => $this->renderView('belege/list', [
|
||||||
|
'rows' => $result['rows'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'page' => $page,
|
||||||
|
'filter' => $filter,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadForm(array $params): void
|
||||||
|
{
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Beleg hochladen',
|
||||||
|
'content' => $this->renderView('belege/upload', ['errors' => []]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function upload(array $params): void
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (empty($_FILES['beleg_file']) || $_FILES['beleg_file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$errors['file'] = 'Keine gültige Datei übermittelt.';
|
||||||
|
} else {
|
||||||
|
$file = $_FILES['beleg_file'];
|
||||||
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
|
$mimeKey = strtolower($mime);
|
||||||
|
|
||||||
|
if (!isset(self::ALLOWED_MIME[$mimeKey])) {
|
||||||
|
$errors['file'] = 'Nicht erlaubter Dateityp. Erlaubt: PDF, PNG, JPEG, TIFF, XML.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||||
|
http_response_code(422);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['error' => implode(' ', $errors)]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Beleg hochladen',
|
||||||
|
'content' => $this->renderView('belege/upload', [
|
||||||
|
'errors' => $errors,
|
||||||
|
'old' => $_POST,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['beleg_file'];
|
||||||
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
|
$ext = self::ALLOWED_MIME[strtolower($mime)];
|
||||||
|
$hash = hash_file('sha256', $file['tmp_name']);
|
||||||
|
$belegNr = Beleg::generateBelegNr();
|
||||||
|
$year = !empty($_POST['beleg_datum'])
|
||||||
|
? date('Y', strtotime($_POST['beleg_datum']))
|
||||||
|
: date('Y');
|
||||||
|
|
||||||
|
// Store under uploads/{year}/{belegNr}/original.{ext}
|
||||||
|
$relDir = "static/uploads/{$year}/{$belegNr}";
|
||||||
|
$absDir = dirname(__DIR__, 2) . "/public/{$relDir}";
|
||||||
|
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
|
||||||
|
|
||||||
|
$filename = pathinfo($file['name'], PATHINFO_BASENAME);
|
||||||
|
$storagePath = "{$relDir}/original.{$ext}";
|
||||||
|
$absPath = dirname(__DIR__, 2) . "/public/{$storagePath}";
|
||||||
|
|
||||||
|
move_uploaded_file($file['tmp_name'], $absPath);
|
||||||
|
|
||||||
|
$id = Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => $filename,
|
||||||
|
'storage_path' => $storagePath,
|
||||||
|
'mime_type' => $mime,
|
||||||
|
'file_hash' => $hash,
|
||||||
|
'beleg_datum' => $_POST['beleg_datum'] ?? null,
|
||||||
|
'eingangs_datum'=> $_POST['eingangs_datum'] ?? date('Y-m-d'),
|
||||||
|
'rechnungs_nr' => $_POST['rechnungs_nr'] ?? null,
|
||||||
|
'betrag_brutto' => $_POST['betrag_brutto'] ?? null,
|
||||||
|
'belegtyp' => $_POST['belegtyp'] ?? 'Eingangsrechnung',
|
||||||
|
'notes' => $_POST['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'XMLHttpRequest') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['id' => $id, 'beleg_nr' => $belegNr]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect("/belege/{$id}", "Beleg {$belegNr} erfolgreich hochgeladen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$beleg = Beleg::find((int)$params['id']);
|
||||||
|
if (!$beleg) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$bookings = Booking::forBeleg((int)$params['id']);
|
||||||
|
$integrity = Beleg::verifyIntegrity((int)$params['id']);
|
||||||
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Beleg ' . $beleg['beleg_nr'],
|
||||||
|
'content' => $this->renderView('belege/detail', [
|
||||||
|
'beleg' => $beleg,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'integrity' => $integrity,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serveFile(array $params): void
|
||||||
|
{
|
||||||
|
$beleg = Beleg::find((int)$params['id']);
|
||||||
|
if (!$beleg) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$absPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
if (!file_exists($absPath)) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
header('Content-Type: ' . $beleg['mime_type']);
|
||||||
|
header('Content-Disposition: inline; filename="' . $beleg['filename'] . '"');
|
||||||
|
header('Content-Length: ' . filesize($absPath));
|
||||||
|
readfile($absPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateMeta(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$beleg = Beleg::find($id);
|
||||||
|
if (!$beleg) { http_response_code(404); return; }
|
||||||
|
if ($beleg['locked']) {
|
||||||
|
View::redirect("/belege/{$id}", 'GoBD: Gesperrter Beleg kann nicht geändert werden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Beleg::updateMeta($id, [
|
||||||
|
'rechnungs_nr' => trim($_POST['rechnungs_nr'] ?? ''),
|
||||||
|
'belegtyp' => $_POST['belegtyp'] ?? $beleg['belegtyp'],
|
||||||
|
'beleg_datum' => $_POST['beleg_datum'] ?: null,
|
||||||
|
'betrag_brutto' => $_POST['betrag_brutto'] !== '' ? $_POST['betrag_brutto'] : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
View::redirect("/belege/{$id}", 'Metadaten gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
if (!Beleg::delete($id)) {
|
||||||
|
View::redirect('/belege', 'Beleg kann nicht gelöscht werden (gesperrt oder enthält Buchungen).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
View::redirect('/belege', 'Beleg gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/Controllers/BookingController.php
Normal file
174
src/Controllers/BookingController.php
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class BookingController
|
||||||
|
{
|
||||||
|
public function journal(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$result = Booking::journal($filter, $page);
|
||||||
|
$years = FiscalYear::availableYears();
|
||||||
|
$accounts = Account::all(['aktiv' => 1]);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Buchungsjournal',
|
||||||
|
'content' => $this->renderView('bookings/journal', [
|
||||||
|
'rows' => $result['rows'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'page' => $page,
|
||||||
|
'filter' => $filter,
|
||||||
|
'years' => $years,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'testMode' => Setting::isTestMode(),
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/journal/export?'.http_build_query($filter).'" class="btn btn-secondary">CSV Export</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function export(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
unset($filter['page']);
|
||||||
|
$result = Booking::journal($filter, 1, 99999);
|
||||||
|
|
||||||
|
header('Content-Type: text/csv; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="buchungsjournal-' . date('Ymd') . '.csv"');
|
||||||
|
|
||||||
|
$out = fopen('php://output', 'w');
|
||||||
|
fprintf($out, chr(0xEF) . chr(0xBB) . chr(0xBF)); // UTF-8 BOM for Excel
|
||||||
|
|
||||||
|
fputcsv($out, [
|
||||||
|
'Buchungsnr.', 'Datum', 'Belegnr.', 'Soll-Kto.', 'Soll-Bez.',
|
||||||
|
'Haben-Kto.', 'Haben-Bez.', 'Netto (€)', 'USt (%)', 'USt (€)',
|
||||||
|
'Brutto (€)', 'Buchungstext', 'Storno'
|
||||||
|
], ';');
|
||||||
|
|
||||||
|
foreach ($result['rows'] as $row) {
|
||||||
|
fputcsv($out, [
|
||||||
|
$row['buchungs_nr'],
|
||||||
|
$row['buchungs_datum'],
|
||||||
|
$row['beleg_nr'],
|
||||||
|
$row['soll_nr'],
|
||||||
|
$row['soll_name'],
|
||||||
|
$row['haben_nr'],
|
||||||
|
$row['haben_name'],
|
||||||
|
number_format((float)$row['betrag_netto'], 2, ',', ''),
|
||||||
|
number_format((float)$row['ust_satz'], 2, ',', ''),
|
||||||
|
number_format((float)$row['betrag_ust'], 2, ',', ''),
|
||||||
|
number_format((float)$row['betrag_brutto'], 2, ',', ''),
|
||||||
|
$row['buchungstext'],
|
||||||
|
$row['is_reversal'] ? 'Ja' : 'Nein',
|
||||||
|
], ';');
|
||||||
|
}
|
||||||
|
fclose($out);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(array $params): void
|
||||||
|
{
|
||||||
|
$belegId = (int)$params['beleg_id'];
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (empty($data['soll_konto_id'])) $errors['soll_konto_id'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['haben_konto_id'])) $errors['haben_konto_id'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['buchungs_datum'])) $errors['buchungs_datum'] = 'Pflichtfeld';
|
||||||
|
if (!isset($data['betrag_netto']) || $data['betrag_netto'] === '')
|
||||||
|
$errors['betrag_netto'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['buchungstext'])) $errors['buchungstext'] = 'Pflichtfeld';
|
||||||
|
|
||||||
|
if (empty($errors) && $data['soll_konto_id'] === $data['haben_konto_id']) {
|
||||||
|
$errors['haben_konto_id'] = 'Soll- und Habenkonto dürfen nicht identisch sein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$this->renderBelegDetail($belegId, $errors, $data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data['beleg_id'] = $belegId;
|
||||||
|
Booking::create($data);
|
||||||
|
View::redirect("/belege/{$belegId}", 'Buchung erfasst.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->renderBelegDetail($belegId, ['_exception' => $e->getMessage()], $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderBelegDetail(int $belegId, array $errors, array $formData = []): void
|
||||||
|
{
|
||||||
|
$beleg = \Steuerkram\Models\Beleg::find($belegId);
|
||||||
|
$bookings = \Steuerkram\Models\Booking::forBeleg($belegId);
|
||||||
|
$accounts = \Steuerkram\Models\Account::all(['aktiv' => 1]);
|
||||||
|
$integrity = \Steuerkram\Models\Beleg::verifyIntegrity($belegId);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Beleg ' . ($beleg['beleg_nr'] ?? ''),
|
||||||
|
'content' => $this->renderView('belege/detail', [
|
||||||
|
'beleg' => $beleg,
|
||||||
|
'bookings' => $bookings,
|
||||||
|
'integrity' => $integrity,
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'formErrors' => $errors,
|
||||||
|
'formData' => $formData,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(array $params): void
|
||||||
|
{
|
||||||
|
if (!Setting::isTestMode()) {
|
||||||
|
View::redirect('/journal', 'Löschen ist im Produktivmodus nicht erlaubt (GoBD).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$booking = Booking::find($id);
|
||||||
|
if (!$booking) {
|
||||||
|
View::redirect('/journal', 'Buchung nicht gefunden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
\Steuerkram\Database::execute('DELETE FROM bookings WHERE id = ?', [$id]);
|
||||||
|
$redirect = !empty($_POST['beleg_id'])
|
||||||
|
? '/belege/' . (int)$_POST['beleg_id']
|
||||||
|
: '/journal';
|
||||||
|
View::redirect($redirect, 'Buchung gelöscht (Testmodus).');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reverse(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$reason = trim($_POST['reason'] ?? 'Manuelle Stornierung');
|
||||||
|
|
||||||
|
try {
|
||||||
|
Booking::reverse($id, $reason);
|
||||||
|
$booking = Booking::find($id);
|
||||||
|
View::redirect("/belege/{$booking['beleg_id']}", 'Buchung wurde storniert.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
View::redirect('/journal', $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
148
src/Controllers/CustomerController.php
Normal file
148
src/Controllers/CustomerController.php
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class CustomerController
|
||||||
|
{
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
$customers = Customer::all($filter);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kunden',
|
||||||
|
'content' => $this->renderView('customers/list', [
|
||||||
|
'customers' => $customers,
|
||||||
|
'filter' => $filter,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/customers/new" class="btn btn-primary">+ Kunde anlegen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $params): void
|
||||||
|
{
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kunde anlegen',
|
||||||
|
'content' => $this->renderView('customers/form', [
|
||||||
|
'customer' => [],
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/customers',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(array $params): void
|
||||||
|
{
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kunde anlegen',
|
||||||
|
'content' => $this->renderView('customers/form', [
|
||||||
|
'customer' => $data,
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/customers',
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer::create($data);
|
||||||
|
View::redirect('/customers', 'Kunde erfolgreich angelegt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$customer = Customer::find((int)$params['id']);
|
||||||
|
if (!$customer) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$invoices = \Steuerkram\Database::fetchAll(
|
||||||
|
'SELECT * FROM invoices WHERE customer_id=? ORDER BY rechnungs_datum DESC LIMIT 20',
|
||||||
|
[$customer['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => Customer::displayName($customer),
|
||||||
|
'content' => $this->renderView('customers/show', [
|
||||||
|
'customer' => $customer,
|
||||||
|
'invoices' => $invoices,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(array $params): void
|
||||||
|
{
|
||||||
|
$customer = Customer::find((int)$params['id']);
|
||||||
|
if (!$customer) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kunde bearbeiten',
|
||||||
|
'content' => $this->renderView('customers/form', [
|
||||||
|
'customer' => $customer,
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/customers/' . $customer['id'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$customer = Customer::find($id);
|
||||||
|
if (!$customer) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Kunde bearbeiten',
|
||||||
|
'content' => $this->renderView('customers/form', [
|
||||||
|
'customer' => array_merge($customer, $data),
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/customers/' . $id,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Customer::update($id, $data);
|
||||||
|
View::redirect('/customers/' . $id, 'Kundendaten aktualisiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archive(array $params): void
|
||||||
|
{
|
||||||
|
Customer::archive((int)$params['id']);
|
||||||
|
View::redirect('/customers', 'Kunde archiviert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
if (empty($data['nachname'])) $errors['nachname'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Controllers/EuerController.php
Normal file
62
src/Controllers/EuerController.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Models\FiscalYear;
|
||||||
|
use Steuerkram\Services\EuerCalculator;
|
||||||
|
use Steuerkram\Services\PdfEuerService;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class EuerController
|
||||||
|
{
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$years = FiscalYear::availableYears();
|
||||||
|
if (empty($years)) $years = [(int)date('Y')];
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'EÜR – Einnahmen-Überschuss-Rechnung',
|
||||||
|
'content' => $this->renderView('tax/euer_index', ['years' => $years]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)$params['year'];
|
||||||
|
$calc = new EuerCalculator();
|
||||||
|
$euer = $calc->calculate($year);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => "EÜR {$year}",
|
||||||
|
'content' => $this->renderView('tax/euer_show', [
|
||||||
|
'euer' => $euer,
|
||||||
|
'year' => $year,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/tax/euer/' . $year . '/pdf" class="btn btn-secondary">PDF herunterladen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdf(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)$params['year'];
|
||||||
|
$calc = new EuerCalculator();
|
||||||
|
$euer = $calc->calculate($year);
|
||||||
|
$settings = Setting::get();
|
||||||
|
|
||||||
|
$svc = new PdfEuerService();
|
||||||
|
$svc->render($euer, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/Controllers/InvoiceController.php
Normal file
325
src/Controllers/InvoiceController.php
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Invoice;
|
||||||
|
use Steuerkram\Models\Customer;
|
||||||
|
use Steuerkram\Models\Beleg;
|
||||||
|
use Steuerkram\Models\Booking;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Services\PdfInvoiceService;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class InvoiceController
|
||||||
|
{
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$filter = $_GET;
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
$result = Invoice::all($filter, $page);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Ausgangsrechnungen',
|
||||||
|
'content' => $this->renderView('invoices/list', [
|
||||||
|
'rows' => $result['rows'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'pages' => $result['pages'],
|
||||||
|
'page' => $page,
|
||||||
|
'filter' => $filter,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/invoices/new" class="btn btn-primary">+ Rechnung erstellen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $params): void
|
||||||
|
{
|
||||||
|
$customers = Customer::all();
|
||||||
|
$settings = Setting::get();
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Neue Rechnung',
|
||||||
|
'content' => $this->renderView('invoices/form', [
|
||||||
|
'invoice' => [
|
||||||
|
'rechnungs_datum' => date('Y-m-d'),
|
||||||
|
'leistungszeitraum_von' => date('Y-m-01'),
|
||||||
|
'leistungszeitraum_bis' => date('Y-m-t'),
|
||||||
|
],
|
||||||
|
'lines' => [],
|
||||||
|
'customers' => $customers,
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/invoices',
|
||||||
|
'settings' => $settings,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(array $params): void
|
||||||
|
{
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$customers = Customer::all();
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Neue Rechnung',
|
||||||
|
'content' => $this->renderView('invoices/form', [
|
||||||
|
'invoice' => $data,
|
||||||
|
'lines' => $data['lines'] ?? [],
|
||||||
|
'customers' => $customers,
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/invoices',
|
||||||
|
'settings' => Setting::get(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = Invoice::create($data);
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung als Entwurf gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$invoice = Invoice::find((int)$params['id']);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$lines = Invoice::lines((int)$params['id']);
|
||||||
|
$settings = Setting::get();
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'),
|
||||||
|
'content' => $this->renderView('invoices/show', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'lines' => $lines,
|
||||||
|
'settings' => $settings,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit(array $params): void
|
||||||
|
{
|
||||||
|
$invoice = Invoice::find((int)$params['id']);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
if ($invoice['status'] !== 'Entwurf') {
|
||||||
|
View::redirect("/invoices/{$invoice['id']}", 'Nur Entwürfe können bearbeitet werden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = Invoice::lines((int)$params['id']);
|
||||||
|
$customers = Customer::all();
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Rechnung bearbeiten',
|
||||||
|
'content' => $this->renderView('invoices/form', [
|
||||||
|
'invoice' => $invoice,
|
||||||
|
'lines' => $lines,
|
||||||
|
'customers' => $customers,
|
||||||
|
'errors' => [],
|
||||||
|
'action' => '/invoices/' . $invoice['id'],
|
||||||
|
'settings' => Setting::get(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$invoice = Invoice::find($id);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = $this->validate($data);
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$customers = Customer::all();
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Rechnung bearbeiten',
|
||||||
|
'content' => $this->renderView('invoices/form', [
|
||||||
|
'invoice' => array_merge($invoice, $data),
|
||||||
|
'lines' => $data['lines'] ?? [],
|
||||||
|
'customers' => $customers,
|
||||||
|
'errors' => $errors,
|
||||||
|
'action' => '/invoices/' . $id,
|
||||||
|
'settings' => Setting::get(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Invoice::update($id, $data);
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung gespeichert.');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finalize(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
try {
|
||||||
|
$nr = Invoice::finalize($id);
|
||||||
|
View::redirect("/invoices/{$id}", "Rechnung {$nr} wurde finalisiert.");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pdf(array $params): void
|
||||||
|
{
|
||||||
|
$invoice = Invoice::find((int)$params['id']);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
$lines = Invoice::lines((int)$params['id']);
|
||||||
|
$settings = Setting::get();
|
||||||
|
|
||||||
|
$svc = new PdfInvoiceService();
|
||||||
|
$svc->render($invoice, $lines, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function book(array $params): void
|
||||||
|
{
|
||||||
|
$id = (int)$params['id'];
|
||||||
|
$invoice = Invoice::find($id);
|
||||||
|
if (!$invoice) { http_response_code(404); return; }
|
||||||
|
|
||||||
|
if ($invoice['status'] === 'Entwurf') {
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung muss zuerst finalisiert werden.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!empty($invoice['beleg_id'])) {
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung wurde bereits gebucht.', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
\Steuerkram\Database::beginTransaction();
|
||||||
|
|
||||||
|
// Create Beleg for this invoice
|
||||||
|
$belegNr = Beleg::generateBelegNr();
|
||||||
|
$belegId = Beleg::create([
|
||||||
|
'beleg_nr' => $belegNr,
|
||||||
|
'filename' => $invoice['rechnungs_nr'] . '.pdf',
|
||||||
|
'storage_path' => '', // no physical file for outgoing invoices
|
||||||
|
'mime_type' => 'application/pdf',
|
||||||
|
'file_hash' => hash('sha256', $invoice['rechnungs_nr']),
|
||||||
|
'beleg_datum' => $invoice['rechnungs_datum'],
|
||||||
|
'eingangs_datum' => $invoice['rechnungs_datum'],
|
||||||
|
'rechnungs_nr' => $invoice['rechnungs_nr'],
|
||||||
|
'betrag_brutto' => $invoice['betrag_brutto'],
|
||||||
|
'belegtyp' => 'Ausgangsrechnung',
|
||||||
|
'notes' => 'Automatisch erstellt aus Rechnung ' . $invoice['rechnungs_nr'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$debitorKonto = \Steuerkram\Database::fetchOne(
|
||||||
|
'SELECT id FROM accounts WHERE kontonummer = ?', ['1400']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Erlöse-Konten nach USt-Satz: 8000 (19%), 8200 (7%), 8400 (0%/steuerfrei)
|
||||||
|
$erloesKonten = [];
|
||||||
|
foreach (\Steuerkram\Database::fetchAll(
|
||||||
|
"SELECT id, kontonummer FROM accounts WHERE kontonummer IN ('8000','8200','8400')"
|
||||||
|
) as $row) {
|
||||||
|
$erloesKonten[$row['kontonummer']] = $row['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buchungen pro USt-Satz aus den Rechnungszeilen
|
||||||
|
$lines = Invoice::lines($id);
|
||||||
|
$gruppenNetto = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$satz = (float)$line['ust_satz'];
|
||||||
|
$netto = round((float)$line['menge'] * (float)$line['einzelpreis'], 2);
|
||||||
|
$gruppenNetto[$satz] = ($gruppenNetto[$satz] ?? 0.0) + $netto;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($gruppenNetto as $satz => $netto) {
|
||||||
|
if (abs($satz - 19) < 0.01) {
|
||||||
|
$habenId = $erloesKonten['8000'] ?? null;
|
||||||
|
} elseif (abs($satz - 7) < 0.01) {
|
||||||
|
$habenId = $erloesKonten['8200'] ?? null;
|
||||||
|
} else {
|
||||||
|
$habenId = $erloesKonten['8400'] ?? null;
|
||||||
|
}
|
||||||
|
if (!$habenId) {
|
||||||
|
throw new \RuntimeException("Erlöskonto für USt-Satz {$satz}% nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Booking::create([
|
||||||
|
'beleg_id' => $belegId,
|
||||||
|
'buchungs_datum' => $invoice['rechnungs_datum'],
|
||||||
|
'soll_konto_id' => $debitorKonto['id'],
|
||||||
|
'haben_konto_id' => $habenId,
|
||||||
|
'betrag_netto' => round($netto, 2),
|
||||||
|
'ust_satz' => $satz,
|
||||||
|
'buchungstext' => 'Ausgangsrechnung ' . $invoice['rechnungs_nr'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link beleg to invoice
|
||||||
|
\Steuerkram\Database::execute(
|
||||||
|
'UPDATE invoices SET beleg_id=? WHERE id=?', [$belegId, $id]
|
||||||
|
);
|
||||||
|
|
||||||
|
\Steuerkram\Database::commit();
|
||||||
|
View::redirect("/invoices/{$id}", 'Rechnung erfolgreich gebucht (Beleg ' . $belegNr . ').');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Steuerkram\Database::rollBack();
|
||||||
|
View::redirect("/invoices/{$id}", $e->getMessage(), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markPaid(array $params): void
|
||||||
|
{
|
||||||
|
Invoice::markPaid((int)$params['id']);
|
||||||
|
View::redirect('/invoices/' . $params['id'], 'Rechnung als bezahlt markiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(array $params): void
|
||||||
|
{
|
||||||
|
Invoice::cancel((int)$params['id']);
|
||||||
|
View::redirect('/invoices/' . $params['id'], 'Rechnung storniert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addLine(array $params): void
|
||||||
|
{
|
||||||
|
// This is handled client-side via JS; redirect back
|
||||||
|
View::redirect('/invoices/' . $params['id'] . '/edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteLine(array $params): void
|
||||||
|
{
|
||||||
|
\Steuerkram\Database::execute(
|
||||||
|
'DELETE FROM invoice_lines WHERE id=? AND invoice_id=?',
|
||||||
|
[(int)$params['line_id'], (int)$params['id']]
|
||||||
|
);
|
||||||
|
View::redirect('/invoices/' . $params['id'] . '/edit', 'Position gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$errors = [];
|
||||||
|
if (empty($data['customer_id'])) $errors['customer_id'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['betreff'])) $errors['betreff'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['rechnungs_datum'])) $errors['rechnungs_datum'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['lines'])) {
|
||||||
|
$errors['lines'] = 'Mindestens eine Position erforderlich.';
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/Controllers/SettingsController.php
Normal file
97
src/Controllers/SettingsController.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Models\Account;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class SettingsController
|
||||||
|
{
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$settings = Setting::get();
|
||||||
|
$filter = $_GET;
|
||||||
|
$accounts = Account::all($filter);
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Einstellungen',
|
||||||
|
'content' => $this->renderView('settings/form', [
|
||||||
|
'settings' => $settings,
|
||||||
|
'errors' => [],
|
||||||
|
'accounts' => $accounts,
|
||||||
|
'filter' => $filter,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(array $params): void
|
||||||
|
{
|
||||||
|
$data = $_POST;
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
if (empty($data['firma'])) $errors['firma'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['strasse'])) $errors['strasse'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['plz'])) $errors['plz'] = 'Pflichtfeld';
|
||||||
|
if (empty($data['ort'])) $errors['ort'] = 'Pflichtfeld';
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Einstellungen',
|
||||||
|
'content' => $this->renderView('settings/form', [
|
||||||
|
'settings' => $data,
|
||||||
|
'errors' => $errors,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting::update($data);
|
||||||
|
View::redirect('/settings', 'Einstellungen gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadLogo(array $params): void
|
||||||
|
{
|
||||||
|
if (empty($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
View::redirect('/settings', 'Kein gültiges Bild übermittelt.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['logo'];
|
||||||
|
$mime = mime_content_type($file['tmp_name']);
|
||||||
|
|
||||||
|
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/svg+xml'])) {
|
||||||
|
View::redirect('/settings', 'Logo muss PNG, JPEG oder SVG sein.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/svg+xml' => 'svg'][$mime];
|
||||||
|
$relPath = 'static/uploads/logo/logo.' . $ext;
|
||||||
|
$absDir = dirname(__DIR__, 2) . '/public/static/uploads/logo';
|
||||||
|
|
||||||
|
if (!is_dir($absDir)) mkdir($absDir, 0755, true);
|
||||||
|
|
||||||
|
move_uploaded_file($file['tmp_name'], dirname(__DIR__, 2) . '/public/' . $relPath);
|
||||||
|
Setting::setLogo($relPath);
|
||||||
|
|
||||||
|
View::redirect('/settings', 'Logo hochgeladen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteLogo(array $params): void
|
||||||
|
{
|
||||||
|
Setting::deleteLogo();
|
||||||
|
View::redirect('/settings', 'Logo gelöscht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/Controllers/UstvaController.php
Normal file
127
src/Controllers/UstvaController.php
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Controllers;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
use Steuerkram\Database;
|
||||||
|
use Steuerkram\Services\UstvaCalculator;
|
||||||
|
use Steuerkram\Services\ElsterXmlService;
|
||||||
|
use Steuerkram\View;
|
||||||
|
|
||||||
|
class UstvaController
|
||||||
|
{
|
||||||
|
public function index(array $params): void
|
||||||
|
{
|
||||||
|
$submissions = Database::fetchAll(
|
||||||
|
'SELECT * FROM ustva_submissions ORDER BY year DESC, month DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
$years = [];
|
||||||
|
for ($y = (int)date('Y'); $y >= 2020; $y--) $years[] = $y;
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => 'Umsatzsteuervoranmeldung',
|
||||||
|
'content' => $this->renderView('tax/ustva_index', [
|
||||||
|
'submissions' => $submissions,
|
||||||
|
'years' => $years,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)$params['year'];
|
||||||
|
$month = (int)$params['month'];
|
||||||
|
|
||||||
|
if ($month < 1 || $month > 12) {
|
||||||
|
View::redirect('/tax/ustva', 'Ungültiger Monat.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$calc = new UstvaCalculator();
|
||||||
|
$kz = $calc->calculate($year, $month);
|
||||||
|
|
||||||
|
// Load saved submission if exists
|
||||||
|
$saved = Database::fetchOne(
|
||||||
|
'SELECT * FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
||||||
|
);
|
||||||
|
|
||||||
|
$settings = Setting::get();
|
||||||
|
$months = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/base.php', [
|
||||||
|
'pageTitle' => "UStVA {$months[$month-1]} {$year}",
|
||||||
|
'content' => $this->renderView('tax/ustva_show', [
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'kz' => $kz,
|
||||||
|
'saved' => $saved,
|
||||||
|
'settings' => $settings,
|
||||||
|
'months' => $months,
|
||||||
|
]),
|
||||||
|
'topbarActions' => '<a href="/tax/ustva/' . $year . '/' . $month . '/xml" class="btn btn-secondary">ELSTER XML herunterladen</a>',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)$params['year'];
|
||||||
|
$month = (int)$params['month'];
|
||||||
|
|
||||||
|
$calc = new UstvaCalculator();
|
||||||
|
$kz = $calc->calculate($year, $month);
|
||||||
|
|
||||||
|
$xmlSvc = new ElsterXmlService();
|
||||||
|
$xml = $xmlSvc->generate($year, $month, $kz);
|
||||||
|
|
||||||
|
// Upsert
|
||||||
|
$existing = Database::fetchOne(
|
||||||
|
'SELECT id FROM ustva_submissions WHERE year=? AND month=?', [$year, $month]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE ustva_submissions SET xml_content=?, field_values=? WHERE year=? AND month=?',
|
||||||
|
[$xml, json_encode($kz, JSON_UNESCAPED_UNICODE), $year, $month]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO ustva_submissions (year, month, xml_content, field_values) VALUES (?,?,?,?)',
|
||||||
|
[$year, $month, $xml, json_encode($kz, JSON_UNESCAPED_UNICODE)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
View::redirect("/tax/ustva/{$year}/{$month}", 'UStVA gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadXml(array $params): void
|
||||||
|
{
|
||||||
|
$year = (int)$params['year'];
|
||||||
|
$month = (int)$params['month'];
|
||||||
|
|
||||||
|
// Recalculate fresh
|
||||||
|
$calc = new UstvaCalculator();
|
||||||
|
$kz = $calc->calculate($year, $month);
|
||||||
|
|
||||||
|
$xmlSvc = new ElsterXmlService();
|
||||||
|
$xml = $xmlSvc->generate($year, $month, $kz);
|
||||||
|
|
||||||
|
$filename = sprintf('UStVA_%04d_%02d.xml', $year, $month);
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
header('Content-Length: ' . strlen($xml));
|
||||||
|
echo $xml;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderView(string $template, array $data): string
|
||||||
|
{
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
ob_start();
|
||||||
|
include dirname(__DIR__) . '/Views/' . $template . '.php';
|
||||||
|
return ob_get_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/Database.php
Normal file
77
src/Database.php
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
class Database
|
||||||
|
{
|
||||||
|
private static ?PDO $instance = null;
|
||||||
|
|
||||||
|
public static function get(): PDO
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
$host = Config::get('db_host');
|
||||||
|
$name = Config::get('db_name');
|
||||||
|
$user = Config::get('db_user');
|
||||||
|
$pass = Config::get('db_pass');
|
||||||
|
|
||||||
|
$dsn = "mysql:host={$host};dbname={$name};charset=utf8mb4";
|
||||||
|
|
||||||
|
self::$instance = new PDO($dsn, $user, $pass, [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience wrappers
|
||||||
|
public static function query(string $sql, array $params = []): \PDOStatement
|
||||||
|
{
|
||||||
|
$stmt = self::get()->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
return $stmt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchAll(string $sql, array $params = []): array
|
||||||
|
{
|
||||||
|
return self::query($sql, $params)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fetchOne(string $sql, array $params = []): array|false
|
||||||
|
{
|
||||||
|
return self::query($sql, $params)->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function execute(string $sql, array $params = []): int
|
||||||
|
{
|
||||||
|
$stmt = self::query($sql, $params);
|
||||||
|
return $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lastInsertId(): string
|
||||||
|
{
|
||||||
|
return self::get()->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function beginTransaction(): void
|
||||||
|
{
|
||||||
|
self::get()->beginTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function commit(): void
|
||||||
|
{
|
||||||
|
self::get()->commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rollBack(): void
|
||||||
|
{
|
||||||
|
self::get()->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
112
src/Models/Account.php
Normal file
112
src/Models/Account.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Account
|
||||||
|
{
|
||||||
|
public static function all(array $filter = []): array
|
||||||
|
{
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filter['search'])) {
|
||||||
|
$where[] = '(kontonummer LIKE ? OR bezeichnung LIKE ?)';
|
||||||
|
$params[] = '%' . $filter['search'] . '%';
|
||||||
|
$params[] = '%' . $filter['search'] . '%';
|
||||||
|
}
|
||||||
|
if (!empty($filter['kontenart'])) {
|
||||||
|
$where[] = 'kontenart = ?';
|
||||||
|
$params[] = $filter['kontenart'];
|
||||||
|
}
|
||||||
|
if (isset($filter['aktiv'])) {
|
||||||
|
$where[] = 'aktiv = ?';
|
||||||
|
$params[] = (int)$filter['aktiv'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'SELECT * FROM accounts WHERE ' . implode(' AND ', $where) . ' ORDER BY kontonummer';
|
||||||
|
return Database::fetchAll($sql, $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM accounts WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByKontonummer(string $nr): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM accounts WHERE kontonummer = ?', [$nr]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function search(string $q, int $limit = 20): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT id, kontonummer, bezeichnung, kontenart FROM accounts
|
||||||
|
WHERE aktiv = 1 AND (kontonummer LIKE ? OR bezeichnung LIKE ?)
|
||||||
|
ORDER BY kontonummer LIMIT ?',
|
||||||
|
['%' . $q . '%', '%' . $q . '%', $limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $data): int
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO accounts (kontonummer, bezeichnung, kontenart, beschreibung, steuercode, ustva_kz)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$data['kontonummer'],
|
||||||
|
$data['bezeichnung'],
|
||||||
|
$data['kontenart'],
|
||||||
|
$data['beschreibung'] ?? null,
|
||||||
|
$data['steuercode'] ?? null,
|
||||||
|
$data['ustva_kz'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return (int)Database::lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, array $data): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE accounts SET kontonummer=?, bezeichnung=?, kontenart=?, beschreibung=?, steuercode=?, ustva_kz=?, aktiv=?
|
||||||
|
WHERE id=?',
|
||||||
|
[
|
||||||
|
$data['kontonummer'],
|
||||||
|
$data['bezeichnung'],
|
||||||
|
$data['kontenart'],
|
||||||
|
$data['beschreibung'] ?? null,
|
||||||
|
$data['steuercode'] ?? null,
|
||||||
|
$data['ustva_kz'] ?? null,
|
||||||
|
(int)($data['aktiv'] ?? 1),
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id): bool
|
||||||
|
{
|
||||||
|
// Check if any bookings reference this account
|
||||||
|
$used = Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt FROM bookings WHERE soll_konto_id = ? OR haben_konto_id = ?',
|
||||||
|
[$id, $id]
|
||||||
|
);
|
||||||
|
if ($used && $used['cnt'] > 0) {
|
||||||
|
return false; // cannot delete
|
||||||
|
}
|
||||||
|
Database::execute('DELETE FROM accounts WHERE id = ? AND is_system = 0', [$id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isKontonummerTaken(string $nr, ?int $excludeId = null): bool
|
||||||
|
{
|
||||||
|
if ($excludeId) {
|
||||||
|
$row = Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ? AND id != ?', [$nr, $excludeId]);
|
||||||
|
} else {
|
||||||
|
$row = Database::fetchOne('SELECT id FROM accounts WHERE kontonummer = ?', [$nr]);
|
||||||
|
}
|
||||||
|
return (bool)$row;
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/Models/Beleg.php
Normal file
218
src/Models/Beleg.php
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Beleg
|
||||||
|
{
|
||||||
|
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
|
||||||
|
{
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filter['search'])) {
|
||||||
|
$where[] = '(b.beleg_nr LIKE ? OR b.filename LIKE ? OR b.rechnungs_nr LIKE ? OR b.notes LIKE ?)';
|
||||||
|
$s = '%' . $filter['search'] . '%';
|
||||||
|
$params = array_merge($params, [$s, $s, $s, $s]);
|
||||||
|
}
|
||||||
|
if (!empty($filter['year'])) {
|
||||||
|
$where[] = 'YEAR(b.beleg_datum) = ?';
|
||||||
|
$params[] = (int)$filter['year'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['month'])) {
|
||||||
|
$where[] = 'MONTH(b.beleg_datum) = ?';
|
||||||
|
$params[] = (int)$filter['month'];
|
||||||
|
}
|
||||||
|
if (isset($filter['booked']) && $filter['booked'] !== '') {
|
||||||
|
$where[] = 'b.booked = ?';
|
||||||
|
$params[] = (int)$filter['booked'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['belegtyp'])) {
|
||||||
|
$where[] = 'b.belegtyp = ?';
|
||||||
|
$params[] = $filter['belegtyp'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$sql = 'SELECT b.*,
|
||||||
|
COALESCE(b.betrag_brutto,
|
||||||
|
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
|
||||||
|
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0)
|
||||||
|
) AS betrag_brutto,
|
||||||
|
fy.year as fiscal_year_nr
|
||||||
|
FROM belege b
|
||||||
|
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
ORDER BY b.eingangs_datum DESC, b.id DESC
|
||||||
|
LIMIT ? OFFSET ?';
|
||||||
|
|
||||||
|
$params[] = $perPage;
|
||||||
|
$params[] = $offset;
|
||||||
|
|
||||||
|
$rows = Database::fetchAll($sql, $params);
|
||||||
|
|
||||||
|
// Count total
|
||||||
|
$countSql = 'SELECT COUNT(*) as cnt FROM belege b WHERE ' . implode(' AND ', $where);
|
||||||
|
$countParams = array_slice($params, 0, -2);
|
||||||
|
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT b.*,
|
||||||
|
COALESCE(b.betrag_brutto,
|
||||||
|
(SELECT SUM(bk.betrag_brutto) FROM bookings bk
|
||||||
|
WHERE bk.beleg_id = b.id AND bk.is_reversal = 0)
|
||||||
|
) AS betrag_brutto,
|
||||||
|
fy.year as fiscal_year_nr
|
||||||
|
FROM belege b
|
||||||
|
LEFT JOIN fiscal_years fy ON b.fiscal_year_id = fy.id
|
||||||
|
WHERE b.id = ?',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $data): int
|
||||||
|
{
|
||||||
|
// Auto-assign fiscal year
|
||||||
|
$fyId = null;
|
||||||
|
if (!empty($data['beleg_datum'])) {
|
||||||
|
$year = (int)date('Y', strtotime($data['beleg_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
$fyId = $fy['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO belege (beleg_nr, filename, storage_path, mime_type, file_hash,
|
||||||
|
beleg_datum, eingangs_datum, rechnungs_nr, betrag_brutto, belegtyp, notes, fiscal_year_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$data['beleg_nr'],
|
||||||
|
$data['filename'],
|
||||||
|
$data['storage_path'],
|
||||||
|
$data['mime_type'],
|
||||||
|
$data['file_hash'],
|
||||||
|
$data['beleg_datum'] ?: null,
|
||||||
|
$data['eingangs_datum'],
|
||||||
|
$data['rechnungs_nr'] ?? null,
|
||||||
|
$data['betrag_brutto'] ?? null,
|
||||||
|
$data['belegtyp'] ?? 'Eingangsrechnung',
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
$fyId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return (int)Database::lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, array $data): void
|
||||||
|
{
|
||||||
|
$beleg = self::find($id);
|
||||||
|
if ($beleg && $beleg['locked']) {
|
||||||
|
throw new \RuntimeException('GoBD: Gesperrter Beleg kann nicht geändert werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fiscal year if date changed
|
||||||
|
$fyId = null;
|
||||||
|
if (!empty($data['beleg_datum'])) {
|
||||||
|
$year = (int)date('Y', strtotime($data['beleg_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
$fyId = $fy['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET beleg_datum=?, rechnungs_nr=?, betrag_brutto=?, belegtyp=?, notes=?,
|
||||||
|
booked=?, fiscal_year_id=?, updated_at=NOW()
|
||||||
|
WHERE id=? AND locked=0',
|
||||||
|
[
|
||||||
|
$data['beleg_datum'] ?: null,
|
||||||
|
$data['rechnungs_nr'] ?? null,
|
||||||
|
$data['betrag_brutto'] ?? null,
|
||||||
|
$data['belegtyp'] ?? 'Eingangsrechnung',
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
(int)($data['booked'] ?? 0),
|
||||||
|
$fyId,
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateMeta(int $id, array $data): void
|
||||||
|
{
|
||||||
|
$fyId = null;
|
||||||
|
if (!empty($data['beleg_datum'])) {
|
||||||
|
$year = (int)date('Y', strtotime($data['beleg_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
$fyId = $fy['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE belege SET
|
||||||
|
rechnungs_nr = ?,
|
||||||
|
belegtyp = ?,
|
||||||
|
beleg_datum = ?,
|
||||||
|
betrag_brutto = ?,
|
||||||
|
fiscal_year_id = COALESCE(?, fiscal_year_id),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = ? AND locked = 0',
|
||||||
|
[
|
||||||
|
$data['rechnungs_nr'] ?: null,
|
||||||
|
$data['belegtyp'],
|
||||||
|
$data['beleg_datum'] ?: null,
|
||||||
|
$data['betrag_brutto'] !== null && $data['betrag_brutto'] !== '' ? (float)$data['betrag_brutto'] : null,
|
||||||
|
$fyId,
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function markBooked(int $id, bool $booked): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE belege SET booked=?, updated_at=NOW() WHERE id=?', [(int)$booked, $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(int $id): bool
|
||||||
|
{
|
||||||
|
$beleg = self::find($id);
|
||||||
|
if (!$beleg) return false;
|
||||||
|
if ($beleg['locked']) return false;
|
||||||
|
|
||||||
|
// Check no bookings exist
|
||||||
|
$cnt = Database::fetchOne('SELECT COUNT(*) as cnt FROM bookings WHERE beleg_id=?', [$id]);
|
||||||
|
if ($cnt && $cnt['cnt'] > 0) return false;
|
||||||
|
|
||||||
|
// Delete file
|
||||||
|
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
if (file_exists($fullPath)) unlink($fullPath);
|
||||||
|
|
||||||
|
Database::execute('DELETE FROM belege WHERE id=?', [$id]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateBelegNr(): string
|
||||||
|
{
|
||||||
|
$settings = Database::fetchOne('SELECT beleg_nr_prefix, beleg_nr_next FROM company_settings WHERE id=1');
|
||||||
|
$prefix = $settings['beleg_nr_prefix'] ?? 'BLG';
|
||||||
|
$next = (int)($settings['beleg_nr_next'] ?? 1);
|
||||||
|
$year = date('Y');
|
||||||
|
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
|
||||||
|
|
||||||
|
Database::execute('UPDATE company_settings SET beleg_nr_next = beleg_nr_next + 1 WHERE id=1');
|
||||||
|
return $nr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyIntegrity(int $id): bool
|
||||||
|
{
|
||||||
|
$beleg = self::find($id);
|
||||||
|
if (!$beleg) return false;
|
||||||
|
|
||||||
|
$fullPath = dirname(__DIR__, 2) . '/public/' . $beleg['storage_path'];
|
||||||
|
if (!file_exists($fullPath)) return false;
|
||||||
|
|
||||||
|
return hash_file('sha256', $fullPath) === $beleg['file_hash'];
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/Models/Booking.php
Normal file
200
src/Models/Booking.php
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Booking
|
||||||
|
{
|
||||||
|
public static function forBeleg(int $belegId): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.*,
|
||||||
|
s.kontonummer as soll_nr, s.bezeichnung as soll_name,
|
||||||
|
h.kontonummer as haben_nr, h.bezeichnung as haben_name
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts s ON bk.soll_konto_id = s.id
|
||||||
|
JOIN accounts h ON bk.haben_konto_id = h.id
|
||||||
|
WHERE bk.beleg_id = ?
|
||||||
|
ORDER BY bk.buchungs_datum, bk.id',
|
||||||
|
[$belegId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function journal(array $filter = [], int $page = 1, int $perPage = 50): array
|
||||||
|
{
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filter['year'])) {
|
||||||
|
$where[] = 'fy.year = ?';
|
||||||
|
$params[] = (int)$filter['year'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['month'])) {
|
||||||
|
$where[] = 'MONTH(bk.buchungs_datum) = ?';
|
||||||
|
$params[] = (int)$filter['month'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['konto'])) {
|
||||||
|
$where[] = '(bk.soll_konto_id = ? OR bk.haben_konto_id = ?)';
|
||||||
|
$params[] = (int)$filter['konto'];
|
||||||
|
$params[] = (int)$filter['konto'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['search'])) {
|
||||||
|
$where[] = 'bk.buchungstext LIKE ?';
|
||||||
|
$params[] = '%' . $filter['search'] . '%';
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$sql = 'SELECT bk.*,
|
||||||
|
s.kontonummer as soll_nr, s.bezeichnung as soll_name,
|
||||||
|
h.kontonummer as haben_nr, h.bezeichnung as haben_name,
|
||||||
|
bl.beleg_nr, fy.year as fiscal_year_nr
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts s ON bk.soll_konto_id = s.id
|
||||||
|
JOIN accounts h ON bk.haben_konto_id = h.id
|
||||||
|
JOIN belege bl ON bk.beleg_id = bl.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
ORDER BY bk.buchungs_datum DESC, bk.id DESC
|
||||||
|
LIMIT ? OFFSET ?';
|
||||||
|
|
||||||
|
$params[] = $perPage;
|
||||||
|
$params[] = $offset;
|
||||||
|
|
||||||
|
$rows = Database::fetchAll($sql, $params);
|
||||||
|
|
||||||
|
$countSql = 'SELECT COUNT(*) as cnt FROM bookings bk
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE ' . implode(' AND ', $where);
|
||||||
|
$countParams = array_slice($params, 0, -2);
|
||||||
|
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM bookings WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $data): int
|
||||||
|
{
|
||||||
|
// Determine fiscal year
|
||||||
|
$year = (int)date('Y', strtotime($data['buchungs_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
|
||||||
|
if ($fy['locked']) {
|
||||||
|
throw new \RuntimeException("Geschäftsjahr {$year} ist gesperrt. Neue Buchungen nicht möglich.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts
|
||||||
|
$netto = round((float)$data['betrag_netto'], 2);
|
||||||
|
$ust = round((float)($data['ust_satz'] ?? 0) / 100 * $netto, 2);
|
||||||
|
$brutto = round($netto + $ust, 2);
|
||||||
|
|
||||||
|
$nr = self::generateBuchungsNr();
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id,
|
||||||
|
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, fiscal_year_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$nr,
|
||||||
|
(int)$data['beleg_id'],
|
||||||
|
$data['buchungs_datum'],
|
||||||
|
(int)$data['soll_konto_id'],
|
||||||
|
(int)$data['haben_konto_id'],
|
||||||
|
$netto,
|
||||||
|
(float)($data['ust_satz'] ?? 0),
|
||||||
|
$ust,
|
||||||
|
$brutto,
|
||||||
|
$data['buchungstext'],
|
||||||
|
$fy['id'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$id = (int)Database::lastInsertId();
|
||||||
|
|
||||||
|
// Mark Beleg as booked
|
||||||
|
Database::execute('UPDATE belege SET booked=1, updated_at=NOW() WHERE id=?', [(int)$data['beleg_id']]);
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function reverse(int $id, string $reason): int
|
||||||
|
{
|
||||||
|
$orig = self::find($id);
|
||||||
|
if (!$orig) throw new \RuntimeException('Buchung nicht gefunden.');
|
||||||
|
if ($orig['is_reversal']) throw new \RuntimeException('Stornobuchungen können nicht storniert werden.');
|
||||||
|
|
||||||
|
// Create reversal: swap Soll/Haben, same amounts
|
||||||
|
$reversalData = [
|
||||||
|
'beleg_id' => $orig['beleg_id'],
|
||||||
|
'buchungs_datum' => date('Y-m-d'),
|
||||||
|
'soll_konto_id' => $orig['haben_konto_id'],
|
||||||
|
'haben_konto_id' => $orig['soll_konto_id'],
|
||||||
|
'betrag_netto' => $orig['betrag_netto'],
|
||||||
|
'ust_satz' => $orig['ust_satz'],
|
||||||
|
'buchungstext' => 'STORNO zu ' . $orig['buchungs_nr'] . ': ' . $reason,
|
||||||
|
];
|
||||||
|
|
||||||
|
$year = (int)date('Y', strtotime($reversalData['buchungs_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
|
||||||
|
$nr = self::generateBuchungsNr();
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO bookings (buchungs_nr, beleg_id, buchungs_datum, soll_konto_id, haben_konto_id,
|
||||||
|
betrag_netto, ust_satz, betrag_ust, betrag_brutto, buchungstext, is_reversal, fiscal_year_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)',
|
||||||
|
[
|
||||||
|
$nr,
|
||||||
|
$reversalData['beleg_id'],
|
||||||
|
$reversalData['buchungs_datum'],
|
||||||
|
$reversalData['soll_konto_id'],
|
||||||
|
$reversalData['haben_konto_id'],
|
||||||
|
$orig['betrag_netto'],
|
||||||
|
$orig['ust_satz'],
|
||||||
|
$orig['betrag_ust'],
|
||||||
|
$orig['betrag_brutto'],
|
||||||
|
$reversalData['buchungstext'],
|
||||||
|
$fy['id'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$reversalId = (int)Database::lastInsertId();
|
||||||
|
|
||||||
|
// Link original to reversal
|
||||||
|
Database::execute('UPDATE bookings SET reversed_by=? WHERE id=?', [$reversalId, $id]);
|
||||||
|
|
||||||
|
return $reversalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateBuchungsNr(): string
|
||||||
|
{
|
||||||
|
$year = date('Y');
|
||||||
|
$result = Database::fetchOne(
|
||||||
|
'SELECT COUNT(*) as cnt FROM bookings WHERE YEAR(buchungs_datum) = ?',
|
||||||
|
[$year]
|
||||||
|
);
|
||||||
|
$next = (int)($result['cnt'] ?? 0) + 1;
|
||||||
|
return sprintf('BU-%s-%06d', $year, $next);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sumByYear(int $year): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.soll_konto_id, bk.haben_konto_id,
|
||||||
|
SUM(bk.betrag_netto) as sum_netto,
|
||||||
|
SUM(bk.betrag_ust) as sum_ust,
|
||||||
|
SUM(bk.betrag_brutto) as sum_brutto
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE fy.year = ?
|
||||||
|
GROUP BY bk.soll_konto_id, bk.haben_konto_id',
|
||||||
|
[$year]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Models/Customer.php
Normal file
115
src/Models/Customer.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Customer
|
||||||
|
{
|
||||||
|
public static function all(array $filter = []): array
|
||||||
|
{
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
$archived = $filter['archived'] ?? 0;
|
||||||
|
$where[] = 'archived = ?';
|
||||||
|
$params[] = (int)$archived;
|
||||||
|
|
||||||
|
if (!empty($filter['search'])) {
|
||||||
|
$where[] = '(kundennummer LIKE ? OR firma LIKE ? OR nachname LIKE ? OR email LIKE ?)';
|
||||||
|
$s = '%' . $filter['search'] . '%';
|
||||||
|
$params = array_merge($params, [$s, $s, $s, $s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT * FROM customers WHERE ' . implode(' AND ', $where) . ' ORDER BY kundennummer',
|
||||||
|
$params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM customers WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $data): int
|
||||||
|
{
|
||||||
|
$nr = self::generateKundennummer();
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO customers (kundennummer, firma, anrede, vorname, nachname, strasse, plz, ort, land,
|
||||||
|
email, telefon, ust_id, steuernummer, payment_days, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$data['kundennummer'] ?? $nr,
|
||||||
|
$data['firma'] ?? null,
|
||||||
|
$data['anrede'] ?? null,
|
||||||
|
$data['vorname'] ?? null,
|
||||||
|
$data['nachname'],
|
||||||
|
$data['strasse'],
|
||||||
|
$data['plz'],
|
||||||
|
$data['ort'],
|
||||||
|
$data['land'] ?? 'DE',
|
||||||
|
$data['email'] ?? null,
|
||||||
|
$data['telefon'] ?? null,
|
||||||
|
$data['ust_id'] ?? null,
|
||||||
|
$data['steuernummer'] ?? null,
|
||||||
|
(int)($data['payment_days'] ?? 14),
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return (int)Database::lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, array $data): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE customers SET firma=?, anrede=?, vorname=?, nachname=?, strasse=?, plz=?, ort=?, land=?,
|
||||||
|
email=?, telefon=?, ust_id=?, steuernummer=?, payment_days=?, notes=?, updated_at=NOW()
|
||||||
|
WHERE id=?',
|
||||||
|
[
|
||||||
|
$data['firma'] ?? null,
|
||||||
|
$data['anrede'] ?? null,
|
||||||
|
$data['vorname'] ?? null,
|
||||||
|
$data['nachname'],
|
||||||
|
$data['strasse'],
|
||||||
|
$data['plz'],
|
||||||
|
$data['ort'],
|
||||||
|
$data['land'] ?? 'DE',
|
||||||
|
$data['email'] ?? null,
|
||||||
|
$data['telefon'] ?? null,
|
||||||
|
$data['ust_id'] ?? null,
|
||||||
|
$data['steuernummer'] ?? null,
|
||||||
|
(int)($data['payment_days'] ?? 14),
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function archive(int $id): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE customers SET archived=1, updated_at=NOW() WHERE id=?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function displayName(array $customer): string
|
||||||
|
{
|
||||||
|
if (!empty($customer['firma'])) {
|
||||||
|
return $customer['firma'];
|
||||||
|
}
|
||||||
|
$parts = array_filter([
|
||||||
|
$customer['anrede'] ?? null,
|
||||||
|
$customer['vorname'] ?? null,
|
||||||
|
$customer['nachname'],
|
||||||
|
]);
|
||||||
|
return implode(' ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateKundennummer(): string
|
||||||
|
{
|
||||||
|
$result = Database::fetchOne('SELECT MAX(CAST(SUBSTRING(kundennummer, 3) AS UNSIGNED)) as max_nr FROM customers WHERE kundennummer REGEXP \'^KD[0-9]+$\'');
|
||||||
|
$next = (int)($result['max_nr'] ?? 0) + 1;
|
||||||
|
return sprintf('KD%04d', $next);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Models/FiscalYear.php
Normal file
51
src/Models/FiscalYear.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class FiscalYear
|
||||||
|
{
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll('SELECT * FROM fiscal_years ORDER BY year DESC');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM fiscal_years WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findByYear(int $year): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM fiscal_years WHERE year = ?', [$year]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findOrCreateByYear(int $year): array
|
||||||
|
{
|
||||||
|
$fy = self::findByYear($year);
|
||||||
|
if ($fy) return $fy;
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO fiscal_years (year, start_date, end_date) VALUES (?, ?, ?)',
|
||||||
|
[$year, "{$year}-01-01", "{$year}-12-31"]
|
||||||
|
);
|
||||||
|
|
||||||
|
return self::findByYear($year);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentYear(): int
|
||||||
|
{
|
||||||
|
return (int)date('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function availableYears(): array
|
||||||
|
{
|
||||||
|
$years = Database::fetchAll(
|
||||||
|
'SELECT DISTINCT YEAR(buchungs_datum) as year FROM bookings ORDER BY year DESC'
|
||||||
|
);
|
||||||
|
return array_column($years, 'year');
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/Models/Invoice.php
Normal file
259
src/Models/Invoice.php
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Invoice
|
||||||
|
{
|
||||||
|
public static function all(array $filter = [], int $page = 1, int $perPage = 30): array
|
||||||
|
{
|
||||||
|
$where = ['1=1'];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if (!empty($filter['status'])) {
|
||||||
|
$where[] = 'i.status = ?';
|
||||||
|
$params[] = $filter['status'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['year'])) {
|
||||||
|
$where[] = 'YEAR(i.rechnungs_datum) = ?';
|
||||||
|
$params[] = (int)$filter['year'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['customer_id'])) {
|
||||||
|
$where[] = 'i.customer_id = ?';
|
||||||
|
$params[] = (int)$filter['customer_id'];
|
||||||
|
}
|
||||||
|
if (!empty($filter['search'])) {
|
||||||
|
$where[] = '(i.rechnungs_nr LIKE ? OR i.betreff LIKE ? OR c.nachname LIKE ? OR c.firma LIKE ?)';
|
||||||
|
$s = '%' . $filter['search'] . '%';
|
||||||
|
$params = array_merge($params, [$s, $s, $s, $s]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$sql = 'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer
|
||||||
|
FROM invoices i
|
||||||
|
JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE ' . implode(' AND ', $where) . '
|
||||||
|
ORDER BY i.rechnungs_datum DESC, i.id DESC
|
||||||
|
LIMIT ? OFFSET ?';
|
||||||
|
|
||||||
|
$params[] = $perPage;
|
||||||
|
$params[] = $offset;
|
||||||
|
$rows = Database::fetchAll($sql, $params);
|
||||||
|
|
||||||
|
$countSql = 'SELECT COUNT(*) as cnt FROM invoices i JOIN customers c ON i.customer_id = c.id WHERE ' . implode(' AND ', $where);
|
||||||
|
$countParams = array_slice($params, 0, -2);
|
||||||
|
$total = (int)(Database::fetchOne($countSql, $countParams)['cnt'] ?? 0);
|
||||||
|
|
||||||
|
return ['rows' => $rows, 'total' => $total, 'pages' => (int)ceil($total / $perPage)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $id): array|false
|
||||||
|
{
|
||||||
|
return Database::fetchOne(
|
||||||
|
'SELECT i.*, c.firma, c.nachname, c.vorname, c.anrede, c.kundennummer,
|
||||||
|
c.strasse, c.plz, c.ort, c.land, c.email, c.payment_days
|
||||||
|
FROM invoices i
|
||||||
|
JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = ?',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function lines(int $invoiceId): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY position',
|
||||||
|
[$invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(array $data): int
|
||||||
|
{
|
||||||
|
$customer = Customer::find((int)$data['customer_id']);
|
||||||
|
$payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14);
|
||||||
|
$datum = $data['rechnungs_datum'] ?? date('Y-m-d');
|
||||||
|
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO invoices (customer_id, betreff, leistungszeitraum_von, leistungszeitraum_bis,
|
||||||
|
rechnungs_datum, faellig_datum, status, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, \'Entwurf\', ?)',
|
||||||
|
[
|
||||||
|
(int)$data['customer_id'],
|
||||||
|
$data['betreff'],
|
||||||
|
$data['leistungszeitraum_von'] ?: null,
|
||||||
|
$data['leistungszeitraum_bis'] ?: null,
|
||||||
|
$datum,
|
||||||
|
$faellig,
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$id = (int)Database::lastInsertId();
|
||||||
|
|
||||||
|
// Create lines
|
||||||
|
if (!empty($data['lines'])) {
|
||||||
|
self::saveLines($id, $data['lines']);
|
||||||
|
self::recalcTotals($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(int $id, array $data): void
|
||||||
|
{
|
||||||
|
$inv = self::find($id);
|
||||||
|
if ($inv && $inv['status'] !== 'Entwurf') {
|
||||||
|
throw new \RuntimeException('Nur Entwürfe können bearbeitet werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = Customer::find((int)$data['customer_id']);
|
||||||
|
$payDays = (int)($data['payment_days'] ?? $customer['payment_days'] ?? 14);
|
||||||
|
$datum = $data['rechnungs_datum'];
|
||||||
|
$faellig = date('Y-m-d', strtotime($datum . " +{$payDays} days"));
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE invoices SET customer_id=?, betreff=?, leistungszeitraum_von=?, leistungszeitraum_bis=?,
|
||||||
|
rechnungs_datum=?, faellig_datum=?, notes=?, updated_at=NOW()
|
||||||
|
WHERE id=? AND status=\'Entwurf\'',
|
||||||
|
[
|
||||||
|
(int)$data['customer_id'],
|
||||||
|
$data['betreff'],
|
||||||
|
$data['leistungszeitraum_von'] ?: null,
|
||||||
|
$data['leistungszeitraum_bis'] ?: null,
|
||||||
|
$datum,
|
||||||
|
$faellig,
|
||||||
|
$data['notes'] ?? null,
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace lines
|
||||||
|
Database::execute('DELETE FROM invoice_lines WHERE invoice_id=?', [$id]);
|
||||||
|
if (!empty($data['lines'])) {
|
||||||
|
self::saveLines($id, $data['lines']);
|
||||||
|
}
|
||||||
|
self::recalcTotals($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function finalize(int $id): string
|
||||||
|
{
|
||||||
|
$inv = self::find($id);
|
||||||
|
if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden.');
|
||||||
|
if ($inv['status'] !== 'Entwurf') throw new \RuntimeException('Rechnung ist kein Entwurf mehr.');
|
||||||
|
|
||||||
|
$nr = self::generateRechnungsNr();
|
||||||
|
|
||||||
|
// Set fiscal year
|
||||||
|
$year = (int)date('Y', strtotime($inv['rechnungs_datum']));
|
||||||
|
$fy = FiscalYear::findOrCreateByYear($year);
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE invoices SET rechnungs_nr=?, status=\'Gesendet\', fiscal_year_id=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[$nr, $fy['id'], $id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $nr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function markPaid(int $id): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE invoices SET status=\'Bezahlt\', updated_at=NOW() WHERE id=?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function cancel(int $id): void
|
||||||
|
{
|
||||||
|
$invoice = self::find($id);
|
||||||
|
if (!$invoice) return;
|
||||||
|
|
||||||
|
// Stornobuchungen für alle gebuchten Buchungen des Belegs erzeugen
|
||||||
|
if (!empty($invoice['beleg_id'])) {
|
||||||
|
$bookings = Database::fetchAll(
|
||||||
|
'SELECT id FROM bookings WHERE beleg_id = ? AND is_reversal = 0 AND reversed_by IS NULL',
|
||||||
|
[(int)$invoice['beleg_id']]
|
||||||
|
);
|
||||||
|
foreach ($bookings as $bk) {
|
||||||
|
Booking::reverse((int)$bk['id'], 'Storno Rechnung ' . ($invoice['rechnungs_nr'] ?? $id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Database::execute('UPDATE invoices SET status=\'Storniert\', updated_at=NOW() WHERE id=?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function saveLines(int $invoiceId, array $lines): void
|
||||||
|
{
|
||||||
|
foreach ($lines as $i => $line) {
|
||||||
|
if (empty($line['bezeichnung'])) continue;
|
||||||
|
$menge = round((float)$line['menge'], 3);
|
||||||
|
$preis = round((float)$line['einzelpreis'], 2);
|
||||||
|
$ustSatz = round((float)$line['ust_satz'], 2);
|
||||||
|
$netto = round($menge * $preis, 2);
|
||||||
|
$ust = round($netto * $ustSatz / 100, 2);
|
||||||
|
$brutto = round($netto + $ust, 2);
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'INSERT INTO invoice_lines (invoice_id, position, bezeichnung, menge, einheit,
|
||||||
|
einzelpreis, ust_satz, betrag_netto, betrag_ust, betrag_brutto)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[
|
||||||
|
$invoiceId,
|
||||||
|
$i + 1,
|
||||||
|
$line['bezeichnung'],
|
||||||
|
$menge,
|
||||||
|
$line['einheit'] ?? 'Stk.',
|
||||||
|
$preis,
|
||||||
|
$ustSatz,
|
||||||
|
$netto,
|
||||||
|
$ust,
|
||||||
|
$brutto,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function recalcTotals(int $id): void
|
||||||
|
{
|
||||||
|
$result = Database::fetchOne(
|
||||||
|
'SELECT SUM(betrag_netto) as netto, SUM(betrag_ust) as ust, SUM(betrag_brutto) as brutto
|
||||||
|
FROM invoice_lines WHERE invoice_id = ?',
|
||||||
|
[$id]
|
||||||
|
);
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE invoices SET betrag_netto=?, betrag_ust=?, betrag_brutto=?, updated_at=NOW() WHERE id=?',
|
||||||
|
[
|
||||||
|
round((float)$result['netto'], 2),
|
||||||
|
round((float)$result['ust'], 2),
|
||||||
|
round((float)$result['brutto'], 2),
|
||||||
|
$id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateRechnungsNr(): string
|
||||||
|
{
|
||||||
|
$settings = Database::fetchOne(
|
||||||
|
'SELECT invoice_nr_prefix, invoice_nr_next, invoice_nr_year FROM company_settings WHERE id=1'
|
||||||
|
);
|
||||||
|
$prefix = $settings['invoice_nr_prefix'] ?? 'AR';
|
||||||
|
$next = (int)($settings['invoice_nr_next'] ?? 1);
|
||||||
|
$savedYear = (int)($settings['invoice_nr_year'] ?? 0);
|
||||||
|
$year = (int)date('Y');
|
||||||
|
|
||||||
|
// Reset counter on new year
|
||||||
|
if ($savedYear !== $year) {
|
||||||
|
$next = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$nr = sprintf('%s-%s-%04d', $prefix, $year, $next);
|
||||||
|
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE company_settings SET invoice_nr_next=?, invoice_nr_year=? WHERE id=1',
|
||||||
|
[$next + 1, $year]
|
||||||
|
);
|
||||||
|
|
||||||
|
return $nr;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/Models/Setting.php
Normal file
73
src/Models/Setting.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Models;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
class Setting
|
||||||
|
{
|
||||||
|
public static function get(): array
|
||||||
|
{
|
||||||
|
return Database::fetchOne('SELECT * FROM company_settings WHERE id = 1') ?: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isTestMode(): bool
|
||||||
|
{
|
||||||
|
$s = self::get();
|
||||||
|
return (bool)($s['test_mode'] ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update(array $data): void
|
||||||
|
{
|
||||||
|
Database::execute(
|
||||||
|
'UPDATE company_settings SET
|
||||||
|
firma=?, inhaber=?, strasse=?, plz=?, ort=?,
|
||||||
|
telefon=?, email=?, website=?,
|
||||||
|
ust_id=?, steuernummer=?, finanzamt=?,
|
||||||
|
iban=?, bic=?, bank_name=?,
|
||||||
|
ustva_steuernummer=?, ustva_bundesland=?,
|
||||||
|
invoice_nr_prefix=?, beleg_nr_prefix=?,
|
||||||
|
payment_days_default=?, test_mode=?
|
||||||
|
WHERE id=1',
|
||||||
|
[
|
||||||
|
$data['firma'] ?? '',
|
||||||
|
$data['inhaber'] ?? '',
|
||||||
|
$data['strasse'] ?? '',
|
||||||
|
$data['plz'] ?? '',
|
||||||
|
$data['ort'] ?? '',
|
||||||
|
$data['telefon'] ?? null,
|
||||||
|
$data['email'] ?? null,
|
||||||
|
$data['website'] ?? null,
|
||||||
|
$data['ust_id'] ?? null,
|
||||||
|
$data['steuernummer'] ?? null,
|
||||||
|
$data['finanzamt'] ?? null,
|
||||||
|
$data['iban'] ?? null,
|
||||||
|
$data['bic'] ?? null,
|
||||||
|
$data['bank_name'] ?? null,
|
||||||
|
$data['ustva_steuernummer'] ?? null,
|
||||||
|
$data['ustva_bundesland'] ?? null,
|
||||||
|
$data['invoice_nr_prefix'] ?? 'AR',
|
||||||
|
$data['beleg_nr_prefix'] ?? 'BLG',
|
||||||
|
(int)($data['payment_days_default'] ?? 14),
|
||||||
|
isset($data['test_mode']) ? 1 : 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function setLogo(string $path): void
|
||||||
|
{
|
||||||
|
Database::execute('UPDATE company_settings SET logo_path=? WHERE id=1', [$path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteLogo(): void
|
||||||
|
{
|
||||||
|
$settings = self::get();
|
||||||
|
if (!empty($settings['logo_path'])) {
|
||||||
|
$fullPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
|
||||||
|
if (file_exists($fullPath)) unlink($fullPath);
|
||||||
|
}
|
||||||
|
Database::execute('UPDATE company_settings SET logo_path=NULL WHERE id=1');
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/Router.php
Normal file
82
src/Router.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
public function add(string $method, string $pattern, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->routes[] = [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'pattern' => $pattern,
|
||||||
|
'handler' => $handler,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $pattern, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('GET', $pattern, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function post(string $pattern, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('POST', $pattern, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $pattern, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('PUT', $pattern, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $pattern, callable $handler): void
|
||||||
|
{
|
||||||
|
$this->add('DELETE', $pattern, $handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(): void
|
||||||
|
{
|
||||||
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
|
// Support method override via hidden _method field or X-HTTP-Method-Override header
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$override = $_POST['_method'] ?? $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? null;
|
||||||
|
if ($override) {
|
||||||
|
$method = strtoupper($override);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
$uri = '/' . trim($uri, '/');
|
||||||
|
if ($uri === '') $uri = '/';
|
||||||
|
|
||||||
|
foreach ($this->routes as $route) {
|
||||||
|
if ($route['method'] !== $method) continue;
|
||||||
|
|
||||||
|
$params = $this->match($route['pattern'], $uri);
|
||||||
|
if ($params !== null) {
|
||||||
|
($route['handler'])($params);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(404);
|
||||||
|
$view = new View();
|
||||||
|
$view->render('layout/404.php', ['uri' => $uri]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function match(string $pattern, string $uri): ?array
|
||||||
|
{
|
||||||
|
// Convert route pattern to regex: {id} → named capture
|
||||||
|
$regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
|
||||||
|
$regex = '#^' . $regex . '$#';
|
||||||
|
|
||||||
|
if (preg_match($regex, $uri, $matches)) {
|
||||||
|
return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
src/Services/ElsterXmlService.php
Normal file
185
src/Services/ElsterXmlService.php
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine ELSTER-konforme XML-Datei für die UStVA.
|
||||||
|
* Kompatibel mit dem Elster Online Portal (Dateiupload, keine ERiC-Signatur nötig).
|
||||||
|
* Schema: ElsterOnline v11
|
||||||
|
*/
|
||||||
|
class ElsterXmlService
|
||||||
|
{
|
||||||
|
public function generate(int $year, int $month, array $kz): string
|
||||||
|
{
|
||||||
|
$settings = Setting::get();
|
||||||
|
|
||||||
|
$steuernummer = preg_replace('/[^0-9]/', '', $settings['ustva_steuernummer'] ?? '');
|
||||||
|
$bundesland = strtoupper($settings['ustva_bundesland'] ?? 'DE');
|
||||||
|
$erstelltAm = date('Y-m-d\TH:i:s');
|
||||||
|
$zeitraum = sprintf('%04d%02d', $year, $month);
|
||||||
|
$firma = htmlspecialchars($settings['firma'] ?? '', ENT_XML1);
|
||||||
|
$email = htmlspecialchars($settings['email'] ?? '', ENT_XML1);
|
||||||
|
|
||||||
|
// Build Kz fields XML
|
||||||
|
$kzXml = '';
|
||||||
|
$kzMap = [
|
||||||
|
'09' => 'Kz09', // Steuernummer (wird im Header gesetzt)
|
||||||
|
'10' => 'Kz10', // Voranmeldungszeitraum
|
||||||
|
'22' => 'Kz22', // Berichtigungsvoranmeldung (0=nein)
|
||||||
|
'81' => 'Kz81',
|
||||||
|
'86' => 'Kz86',
|
||||||
|
'35' => 'Kz35',
|
||||||
|
'41' => 'Kz41',
|
||||||
|
'44' => 'Kz44',
|
||||||
|
'83' => 'Kz83',
|
||||||
|
'84' => 'Kz84',
|
||||||
|
'66' => 'Kz66',
|
||||||
|
'61' => 'Kz61',
|
||||||
|
'62' => 'Kz62',
|
||||||
|
'69' => 'Kz69',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper: format amount for ELSTER (integer cents, no decimals for Beträge; or in EUR with 2 dec)
|
||||||
|
$fmtBetrag = fn($v) => $v !== null ? number_format((float)$v, 2, '.', '') : '0.00';
|
||||||
|
$fmtSteuer = fn($v) => $v !== null ? number_format((float)$v, 2, '.', '') : '0.00';
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
|
||||||
|
// Steuernummer und Voranmeldungszeitraum
|
||||||
|
$fields['09'] = $steuernummer;
|
||||||
|
$fields['10'] = $zeitraum;
|
||||||
|
$fields['22'] = '0'; // Keine Berichtigungsvoranmeldung
|
||||||
|
|
||||||
|
foreach ($kz as $nr => $data) {
|
||||||
|
if ($data['betrag'] !== null && $data['betrag'] != 0) {
|
||||||
|
$fields[$nr . '_betrag'] = $fmtBetrag($data['betrag']);
|
||||||
|
}
|
||||||
|
if ($data['steuer'] !== null && $data['steuer'] != 0) {
|
||||||
|
$fields[$nr . '_steuer'] = $fmtSteuer($data['steuer']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build XML
|
||||||
|
$dom = new \DOMDocument('1.0', 'UTF-8');
|
||||||
|
$dom->formatOutput = true;
|
||||||
|
|
||||||
|
$elster = $dom->createElementNS('http://www.elster.de/elsterxml/schema/v11', 'Elster');
|
||||||
|
$dom->appendChild($elster);
|
||||||
|
|
||||||
|
// TransferHeader
|
||||||
|
$th = $dom->createElement('TransferHeader');
|
||||||
|
$th->appendChild($dom->createElement('Version', '11'));
|
||||||
|
$th->appendChild($dom->createElement('TransferTicket', ''));
|
||||||
|
$th->appendChild($dom->createElement('Testmerker', '700000004'));
|
||||||
|
$th->appendChild($dom->createElement('HerstellerID', '74931'));
|
||||||
|
$th->appendChild($dom->createElement('DatenArt', 'UStVA'));
|
||||||
|
$th->appendChild($dom->createElement('Vorgang', 'send-Auth'));
|
||||||
|
$elster->appendChild($th);
|
||||||
|
|
||||||
|
// DatenTeil
|
||||||
|
$dt = $dom->createElement('DatenTeil');
|
||||||
|
$elster->appendChild($dt);
|
||||||
|
|
||||||
|
$nb = $dom->createElement('Nutzdatenblock');
|
||||||
|
$dt->appendChild($nb);
|
||||||
|
|
||||||
|
// NutzdatenHeader
|
||||||
|
$ndh = $dom->createElement('NutzdatenHeader');
|
||||||
|
$ndh->setAttribute('version', '11');
|
||||||
|
$ndh->appendChild($dom->createElement('NutzdatenTicket', '1'));
|
||||||
|
$empf = $dom->createElement('Empfaenger');
|
||||||
|
$empf->setAttribute('id', 'F');
|
||||||
|
$empf->nodeValue = $this->getFinanzamtCode($bundesland);
|
||||||
|
$ndh->appendChild($empf);
|
||||||
|
$hersteller = $dom->createElement('Hersteller');
|
||||||
|
$hersteller->appendChild($dom->createElement('ProduktName', 'Steuerkram'));
|
||||||
|
$hersteller->appendChild($dom->createElement('ProduktVersion', '1.0'));
|
||||||
|
$ndh->appendChild($hersteller);
|
||||||
|
$nb->appendChild($ndh);
|
||||||
|
|
||||||
|
// Nutzdaten
|
||||||
|
$nd = $dom->createElement('Nutzdaten');
|
||||||
|
$nb->appendChild($nd);
|
||||||
|
|
||||||
|
// Anmeldungssteuern
|
||||||
|
$anm = $dom->createElement('Anmeldungssteuern');
|
||||||
|
$anm->setAttribute('art', 'UStVA');
|
||||||
|
$anm->setAttribute('version', sprintf('%04d%02d', $year, $month));
|
||||||
|
$nd->appendChild($anm);
|
||||||
|
|
||||||
|
// DatenLieferant
|
||||||
|
$dl = $dom->createElement('DatenLieferant');
|
||||||
|
$dl->appendChild($dom->createElement('Name', $firma));
|
||||||
|
$dl->appendChild($dom->createElement('Strasse', htmlspecialchars($settings['strasse'] ?? '', ENT_XML1)));
|
||||||
|
$dl->appendChild($dom->createElement('PLZ', htmlspecialchars($settings['plz'] ?? '', ENT_XML1)));
|
||||||
|
$dl->appendChild($dom->createElement('Ort', htmlspecialchars($settings['ort'] ?? '', ENT_XML1)));
|
||||||
|
if ($email) $dl->appendChild($dom->createElement('Email', $email));
|
||||||
|
$anm->appendChild($dl);
|
||||||
|
|
||||||
|
// Erstellungsdatum
|
||||||
|
$anm->appendChild($dom->createElement('Erstellungsdatum', date('Ymd')));
|
||||||
|
|
||||||
|
// Steuerfall
|
||||||
|
$sf = $dom->createElement('Steuerfall');
|
||||||
|
$anm->appendChild($sf);
|
||||||
|
|
||||||
|
$ustva = $dom->createElement('Umsatzsteuervoranmeldung');
|
||||||
|
$sf->appendChild($ustva);
|
||||||
|
|
||||||
|
// Jahr, Zeitraum
|
||||||
|
$ustva->appendChild($dom->createElement('Jahr', (string)$year));
|
||||||
|
$ustva->appendChild($dom->createElement('Zeitraum', sprintf('%02d', $month)));
|
||||||
|
|
||||||
|
// Steuernummer
|
||||||
|
$ustva->appendChild($dom->createElement('Steuernummer', $steuernummer));
|
||||||
|
|
||||||
|
// Kz-Felder
|
||||||
|
// Kz81 (Umsatz 19%)
|
||||||
|
if (!empty($kz['81']['betrag'])) {
|
||||||
|
$kzEl = $dom->createElement('Kz81', (string)round($kz['81']['betrag']));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
}
|
||||||
|
// Kz86 (Umsatz 7%)
|
||||||
|
if (!empty($kz['86']['betrag'])) {
|
||||||
|
$kzEl = $dom->createElement('Kz86', (string)round($kz['86']['betrag']));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
}
|
||||||
|
// Kz35 (steuerfreie Umsätze)
|
||||||
|
if (!empty($kz['35']['betrag'])) {
|
||||||
|
$kzEl = $dom->createElement('Kz35', (string)round($kz['35']['betrag']));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
}
|
||||||
|
// Kz83 (USt 19%)
|
||||||
|
$kzEl = $dom->createElement('Kz83', (string)(round($kz['83']['steuer'] * 100)));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
// Kz84 (USt 7%)
|
||||||
|
$kzEl = $dom->createElement('Kz84', (string)(round($kz['84']['steuer'] * 100)));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
// Kz66 (Vorsteuer gesamt)
|
||||||
|
if (!empty($kz['66']['steuer'])) {
|
||||||
|
$kzEl = $dom->createElement('Kz66', (string)(round($kz['66']['steuer'] * 100)));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
}
|
||||||
|
// Kz69 (Vorauszahlung / Überschuss)
|
||||||
|
$kzEl = $dom->createElement('Kz69', (string)(round($kz['69']['steuer'] * 100)));
|
||||||
|
$ustva->appendChild($kzEl);
|
||||||
|
|
||||||
|
return $dom->saveXML();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getFinanzamtCode(string $bundesland): string
|
||||||
|
{
|
||||||
|
// Standard-Finanzamt-Code pro Bundesland für ELSTER
|
||||||
|
$codes = [
|
||||||
|
'BW' => '2800', 'BY' => '9200', 'BE' => '1100', 'BB' => '3000',
|
||||||
|
'HB' => '2400', 'HH' => '2200', 'HE' => '2600', 'MV' => '4000',
|
||||||
|
'NI' => '2300', 'NW' => '5100', 'RP' => '2700', 'SL' => '1001',
|
||||||
|
'SN' => '3200', 'ST' => '3100', 'SH' => '2100', 'TH' => '4100',
|
||||||
|
];
|
||||||
|
return $codes[$bundesland] ?? '9999';
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Services/EuerCalculator.php
Normal file
62
src/Services/EuerCalculator.php
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet die Einnahmen-Überschuss-Rechnung (EÜR) für ein Geschäftsjahr.
|
||||||
|
* Gruppiert alle Buchungen nach Konten und summiert Einnahmen / Ausgaben.
|
||||||
|
*/
|
||||||
|
class EuerCalculator
|
||||||
|
{
|
||||||
|
public function calculate(int $year): array
|
||||||
|
{
|
||||||
|
$rows = Database::fetchAll(
|
||||||
|
'SELECT
|
||||||
|
a.id,
|
||||||
|
a.kontonummer,
|
||||||
|
a.bezeichnung,
|
||||||
|
a.kontenart,
|
||||||
|
SUM(CASE WHEN bk.haben_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as haben_summe,
|
||||||
|
SUM(CASE WHEN bk.soll_konto_id = a.id THEN bk.betrag_netto ELSE 0 END) as soll_summe,
|
||||||
|
COUNT(bk.id) as buchungsanzahl
|
||||||
|
FROM accounts a
|
||||||
|
JOIN bookings bk ON (bk.haben_konto_id = a.id OR bk.soll_konto_id = a.id)
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE fy.year = ? AND bk.is_reversal = 0
|
||||||
|
GROUP BY a.id, a.kontonummer, a.bezeichnung, a.kontenart
|
||||||
|
HAVING buchungsanzahl > 0
|
||||||
|
ORDER BY a.kontonummer',
|
||||||
|
[$year]
|
||||||
|
);
|
||||||
|
|
||||||
|
$einnahmen = [];
|
||||||
|
$ausgaben = [];
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$nettoWert = round((float)$row['haben_summe'] - (float)$row['soll_summe'], 2);
|
||||||
|
|
||||||
|
if ($row['kontenart'] === 'Ertrag') {
|
||||||
|
$einnahmen[] = array_merge($row, ['netto' => abs($nettoWert)]);
|
||||||
|
} elseif ($row['kontenart'] === 'Aufwand') {
|
||||||
|
$ausgaben[] = array_merge($row, ['netto' => abs($nettoWert)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalEinnahmen = array_sum(array_column($einnahmen, 'netto'));
|
||||||
|
$totalAusgaben = array_sum(array_column($ausgaben, 'netto'));
|
||||||
|
$gewinn = round($totalEinnahmen - $totalAusgaben, 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'year' => $year,
|
||||||
|
'einnahmen' => $einnahmen,
|
||||||
|
'ausgaben' => $ausgaben,
|
||||||
|
'total_einnahmen' => round($totalEinnahmen, 2),
|
||||||
|
'total_ausgaben' => round($totalAusgaben, 2),
|
||||||
|
'gewinn' => $gewinn,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
124
src/Services/PdfEuerService.php
Normal file
124
src/Services/PdfEuerService.php
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Mpdf\Mpdf;
|
||||||
|
|
||||||
|
class PdfEuerService
|
||||||
|
{
|
||||||
|
public function render(array $euer, array $settings): void
|
||||||
|
{
|
||||||
|
$html = $this->buildHtml($euer, $settings);
|
||||||
|
|
||||||
|
$mpdf = new Mpdf([
|
||||||
|
'mode' => 'utf-8',
|
||||||
|
'format' => 'A4',
|
||||||
|
'margin_top' => 20,
|
||||||
|
'margin_bottom' => 20,
|
||||||
|
'margin_left' => 20,
|
||||||
|
'margin_right' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mpdf->SetTitle('EÜR ' . $euer['year']);
|
||||||
|
$mpdf->SetAuthor($settings['firma'] ?? '');
|
||||||
|
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
||||||
|
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
||||||
|
$mpdf->Output('EÜR-' . $euer['year'] . '.pdf', 'D');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHtml(array $euer, array $settings): string
|
||||||
|
{
|
||||||
|
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
||||||
|
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
|
|
||||||
|
$firma = $h($settings['firma'] ?? '');
|
||||||
|
$year = $euer['year'];
|
||||||
|
|
||||||
|
// Build table rows
|
||||||
|
$rows = '';
|
||||||
|
$maxRows = max(count($euer['einnahmen']), count($euer['ausgaben']));
|
||||||
|
for ($i = 0; $i < $maxRows; $i++) {
|
||||||
|
$e = $euer['einnahmen'][$i] ?? null;
|
||||||
|
$a = $euer['ausgaben'][$i] ?? null;
|
||||||
|
$rows .= '<tr>';
|
||||||
|
$rows .= $e
|
||||||
|
? '<td class="konto-nr">' . $h($e['kontonummer']) . '</td><td class="konto-bez">' . $h($e['bezeichnung']) . '</td><td class="konto-val">' . $fmt($e['netto']) . '</td>'
|
||||||
|
: '<td colspan="3"> </td>';
|
||||||
|
$rows .= '<td class="sep"></td>';
|
||||||
|
$rows .= $a
|
||||||
|
? '<td class="konto-nr">' . $h($a['kontonummer']) . '</td><td class="konto-bez">' . $h($a['bezeichnung']) . '</td><td class="konto-val">' . $fmt($a['netto']) . '</td>'
|
||||||
|
: '<td colspan="3"> </td>';
|
||||||
|
$rows .= '</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$gewinnLabel = $euer['gewinn'] >= 0 ? 'Überschuss (Gewinn)' : 'Fehlbetrag (Verlust)';
|
||||||
|
$gewinnClass = $euer['gewinn'] >= 0 ? 'positive' : 'negative';
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<h1>Einnahmen-Überschuss-Rechnung {$year}</h1>
|
||||||
|
<p class="subtitle">{$firma} · Geschäftsjahr {$year}</p>
|
||||||
|
|
||||||
|
<table class="euer-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="3">Betriebseinnahmen</th>
|
||||||
|
<th class="sep"></th>
|
||||||
|
<th colspan="3">Betriebsausgaben</th>
|
||||||
|
</tr>
|
||||||
|
<tr class="sub-header">
|
||||||
|
<th>Kto.</th><th>Bezeichnung</th><th>Betrag</th>
|
||||||
|
<th class="sep"></th>
|
||||||
|
<th>Kto.</th><th>Bezeichnung</th><th>Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{$rows}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="2"><strong>Summe Einnahmen</strong></td>
|
||||||
|
<td class="konto-val"><strong>{$fmt($euer['total_einnahmen'])}</strong></td>
|
||||||
|
<td class="sep"></td>
|
||||||
|
<td colspan="2"><strong>Summe Ausgaben</strong></td>
|
||||||
|
<td class="konto-val"><strong>{$fmt($euer['total_ausgaben'])}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr class="gewinn-row {$gewinnClass}">
|
||||||
|
<td colspan="7" class="gewinn-cell">
|
||||||
|
<strong>{$gewinnLabel}:</strong>
|
||||||
|
<span class="gewinn-val">{$fmt($euer['gewinn'])}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class="generated-note">Erstellt am {$h(date('d.m.Y H:i'))} · Steuerkram</p>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCss(): string
|
||||||
|
{
|
||||||
|
return <<<CSS
|
||||||
|
body { font-family: DejaVu Sans, sans-serif; font-size: 9pt; }
|
||||||
|
h1 { font-size: 14pt; margin-bottom: 2mm; }
|
||||||
|
.subtitle { font-size: 9pt; color: #555; margin-bottom: 8mm; }
|
||||||
|
.euer-table { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
|
||||||
|
.euer-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; }
|
||||||
|
.euer-table .sub-header th { background: #334155; color: #e2e8f0; font-size: 7.5pt; }
|
||||||
|
.euer-table tbody tr:nth-child(even) { background: #f8f8f8; }
|
||||||
|
.euer-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.2pt solid #e0e0e0; }
|
||||||
|
.konto-nr { width: 12mm; color: #555; }
|
||||||
|
.konto-bez { }
|
||||||
|
.konto-val { width: 24mm; text-align: right; font-family: DejaVu Sans Mono, monospace; }
|
||||||
|
.sep { width: 3mm; background: #fff; }
|
||||||
|
.total-row td { padding: 2mm; background: #e8ecf0; border-top: 1pt solid #555; font-size: 9pt; }
|
||||||
|
.gewinn-row td { padding: 3mm; }
|
||||||
|
.gewinn-row.positive td { background: #dcfce7; }
|
||||||
|
.gewinn-row.negative td { background: #fee2e2; }
|
||||||
|
.gewinn-cell { text-align: right; font-size: 11pt; }
|
||||||
|
.gewinn-val { margin-left: 4mm; }
|
||||||
|
.generated-note { font-size: 7pt; color: #888; margin-top: 8mm; }
|
||||||
|
CSS;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/Services/PdfInvoiceService.php
Normal file
244
src/Services/PdfInvoiceService.php
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Mpdf\Mpdf;
|
||||||
|
use Mpdf\Config\ConfigVariables;
|
||||||
|
use Mpdf\Config\FontVariables;
|
||||||
|
|
||||||
|
class PdfInvoiceService
|
||||||
|
{
|
||||||
|
public function render(array $invoice, array $lines, array $settings): void
|
||||||
|
{
|
||||||
|
$html = $this->buildHtml($invoice, $lines, $settings);
|
||||||
|
|
||||||
|
$mpdf = new Mpdf([
|
||||||
|
'mode' => 'utf-8',
|
||||||
|
'format' => 'A4',
|
||||||
|
'margin_top' => 15,
|
||||||
|
'margin_bottom' => 25,
|
||||||
|
'margin_left' => 20,
|
||||||
|
'margin_right' => 20,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mpdf->SetTitle('Rechnung ' . ($invoice['rechnungs_nr'] ?? 'Entwurf'));
|
||||||
|
$mpdf->SetAuthor($settings['firma'] ?? '');
|
||||||
|
|
||||||
|
$mpdf->SetHTMLFooter($this->buildFooter($settings));
|
||||||
|
$mpdf->WriteHTML($this->buildCss(), \Mpdf\HTMLParserMode::HEADER_CSS);
|
||||||
|
$mpdf->WriteHTML($html, \Mpdf\HTMLParserMode::HTML_BODY);
|
||||||
|
|
||||||
|
$filename = 'Rechnung-' . ($invoice['rechnungs_nr'] ?? 'Entwurf') . '.pdf';
|
||||||
|
$mpdf->Output($filename, 'D'); // D = Download
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildHtml(array $inv, array $lines, array $settings): string
|
||||||
|
{
|
||||||
|
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
||||||
|
$fmt = fn($v) => number_format((float)$v, 2, ',', '.') . ' €';
|
||||||
|
$fmtNum = fn($v) => number_format((float)$v, 3, ',', '.');
|
||||||
|
$fmtDate = fn($v) => $v ? date('d.m.Y', strtotime($v)) : '–';
|
||||||
|
|
||||||
|
// Customer address
|
||||||
|
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['anrede'] ?? '') . ' ' . ($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
$logoHtml = '';
|
||||||
|
if (!empty($settings['logo_path'])) {
|
||||||
|
$logoPath = dirname(__DIR__, 2) . '/public/' . $settings['logo_path'];
|
||||||
|
if (file_exists($logoPath)) {
|
||||||
|
$logoHtml = '<img src="' . $h($settings['logo_path']) . '" alt="Logo" class="logo">';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leistungszeitraum
|
||||||
|
$lz = '';
|
||||||
|
if (!empty($inv['leistungszeitraum_von'])) {
|
||||||
|
$lz = $fmtDate($inv['leistungszeitraum_von']);
|
||||||
|
if (!empty($inv['leistungszeitraum_bis']) && $inv['leistungszeitraum_bis'] !== $inv['leistungszeitraum_von']) {
|
||||||
|
$lz .= ' – ' . $fmtDate($inv['leistungszeitraum_bis']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build positions table
|
||||||
|
$posRows = '';
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$posRows .= '<tr>
|
||||||
|
<td class="pos-nr">' . $h($line['position']) . '</td>
|
||||||
|
<td class="pos-bez">' . $h($line['bezeichnung']) . '</td>
|
||||||
|
<td class="pos-menge">' . $fmtNum($line['menge']) . '</td>
|
||||||
|
<td class="pos-einheit">' . $h($line['einheit']) . '</td>
|
||||||
|
<td class="pos-ust">' . $h(number_format((float)$line['ust_satz'], 0)) . ' %</td>
|
||||||
|
<td class="pos-preis">' . $fmt($line['einzelpreis']) . '</td>
|
||||||
|
<td class="pos-gesamt">' . $fmt($line['betrag_brutto']) . '</td>
|
||||||
|
</tr>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$payDays = $inv['payment_days'] ?? 14;
|
||||||
|
|
||||||
|
return <<<HTML
|
||||||
|
<div class="invoice-wrap">
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="logo-wrap">{$logoHtml}</div>
|
||||||
|
|
||||||
|
<!-- Adressbereich (DIN 5008) -->
|
||||||
|
<div class="address-block">
|
||||||
|
<div class="address-left">
|
||||||
|
<div class="return-address">{$h($settings['firma'] ?? '')} · {$h($settings['strasse'] ?? '')} · {$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</div>
|
||||||
|
<div class="recipient">
|
||||||
|
{$h($customerName)}<br>
|
||||||
|
{$h($inv['strasse'])}<br>
|
||||||
|
{$h($inv['plz'])} {$h($inv['ort'])}<br>
|
||||||
|
{$h($inv['land'] !== 'DE' ? $inv['land'] : '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="address-right">
|
||||||
|
<table class="sender-table">
|
||||||
|
<tr><td>{$h($settings['firma'] ?? '')}</td></tr>
|
||||||
|
<tr><td>{$h($settings['inhaber'] ?? '')}</td></tr>
|
||||||
|
<tr><td>{$h($settings['strasse'] ?? '')}</td></tr>
|
||||||
|
<tr><td>{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}</td></tr>
|
||||||
|
<tr><td> </td></tr>
|
||||||
|
<tr><td>{$h($settings['telefon'] ?? '')}</td></tr>
|
||||||
|
<tr><td>{$h($settings['email'] ?? '')}</td></tr>
|
||||||
|
<tr><td>USt-ID: {$h($settings['ust_id'] ?? '')}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta-Felder -->
|
||||||
|
<table class="meta-table">
|
||||||
|
<tr>
|
||||||
|
<th>Betreff</th>
|
||||||
|
<th>Rechnungsnummer</th>
|
||||||
|
<th>Kundennummer</th>
|
||||||
|
<th>Leistungszeitraum</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>{$h($inv['betreff'])}</td>
|
||||||
|
<td>{$h($inv['rechnungs_nr'] ?? 'Entwurf')}</td>
|
||||||
|
<td>{$h($inv['kundennummer'])}</td>
|
||||||
|
<td>{$h($lz)}</td>
|
||||||
|
<td>{$fmtDate($inv['rechnungs_datum'])}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Positionen -->
|
||||||
|
<table class="pos-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="pos-nr">Pos.</th>
|
||||||
|
<th class="pos-bez">Bezeichnung</th>
|
||||||
|
<th class="pos-menge">Menge</th>
|
||||||
|
<th class="pos-einheit">Einheit</th>
|
||||||
|
<th class="pos-ust">USt.</th>
|
||||||
|
<th class="pos-preis">Einzelpreis</th>
|
||||||
|
<th class="pos-gesamt">Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{$posRows}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Summen -->
|
||||||
|
<table class="sum-table">
|
||||||
|
<tr><td>Nettobetrag:</td><td class="sum-val">{$fmt($inv['betrag_netto'])}</td></tr>
|
||||||
|
<tr><td>zzgl. Umsatzsteuer:</td><td class="sum-val">{$fmt($inv['betrag_ust'])}</td></tr>
|
||||||
|
<tr class="sum-total"><td><strong>Rechnungsbetrag:</strong></td><td class="sum-val"><strong>{$fmt($inv['betrag_brutto'])}</strong></td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Zahlungshinweis -->
|
||||||
|
<p class="payment-note">
|
||||||
|
Bitte überweisen Sie den Rechnungsbetrag innerhalb {$h($payDays)} Tagen auf das unten genannte Konto.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFooter(array $settings): string
|
||||||
|
{
|
||||||
|
$h = fn($v) => htmlspecialchars((string)$v, ENT_QUOTES, 'UTF-8');
|
||||||
|
return <<<HTML
|
||||||
|
<table class="footer-table">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{$h($settings['firma'] ?? '')}</strong><br>
|
||||||
|
{$h($settings['inhaber'] ?? '')}<br>
|
||||||
|
{$h($settings['strasse'] ?? '')}<br>
|
||||||
|
{$h($settings['plz'] ?? '')} {$h($settings['ort'] ?? '')}<br>
|
||||||
|
USt-ID: {$h($settings['ust_id'] ?? '')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
Tel: {$h($settings['telefon'] ?? '')}<br>
|
||||||
|
E-Mail: {$h($settings['email'] ?? '')}<br>
|
||||||
|
Web: {$h($settings['website'] ?? '')}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{$h($settings['bank_name'] ?? '')}<br>
|
||||||
|
IBAN: {$h($settings['iban'] ?? '')}<br>
|
||||||
|
BIC: {$h($settings['bic'] ?? '')}
|
||||||
|
</td>
|
||||||
|
<td class="page-nr">
|
||||||
|
Seite {PAGENO} von {nbpg}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCss(): string
|
||||||
|
{
|
||||||
|
return <<<CSS
|
||||||
|
body { font-family: DejaVu Sans, sans-serif; font-size: 10pt; color: #000; }
|
||||||
|
.invoice-wrap { width: 100%; }
|
||||||
|
.logo-wrap { text-align: center; margin-bottom: 10mm; }
|
||||||
|
.logo { max-height: 25mm; max-width: 80mm; }
|
||||||
|
|
||||||
|
/* DIN 5008 Adressbereich */
|
||||||
|
.address-block { display: flex; justify-content: space-between; margin-bottom: 10mm; }
|
||||||
|
.address-left { width: 85mm; }
|
||||||
|
.return-address { font-size: 7pt; color: #555; border-bottom: 0.3pt solid #aaa; padding-bottom: 1mm; margin-bottom: 2mm; }
|
||||||
|
.recipient { font-size: 10pt; line-height: 1.4; min-height: 27.3mm; }
|
||||||
|
.address-right { width: 75mm; text-align: left; }
|
||||||
|
.sender-table td { font-size: 9pt; padding: 0.5mm 0; }
|
||||||
|
|
||||||
|
/* Meta-Felder */
|
||||||
|
.meta-table { width: 100%; border-collapse: collapse; margin-bottom: 8mm; font-size: 9pt; }
|
||||||
|
.meta-table th { background: #f0f0f0; padding: 2mm 2mm; text-align: left; font-size: 8pt; color: #555; border: 0.5pt solid #ccc; }
|
||||||
|
.meta-table td { padding: 2mm; border: 0.5pt solid #ccc; }
|
||||||
|
|
||||||
|
/* Positionen */
|
||||||
|
.pos-table { width: 100%; border-collapse: collapse; margin-bottom: 5mm; font-size: 9pt; }
|
||||||
|
.pos-table thead th { background: #1e293b; color: #fff; padding: 2mm; text-align: left; }
|
||||||
|
.pos-table tbody tr:nth-child(even) { background: #f8f8f8; }
|
||||||
|
.pos-table tbody td { padding: 1.5mm 2mm; border-bottom: 0.3pt solid #e0e0e0; }
|
||||||
|
.pos-nr { width: 10mm; }
|
||||||
|
.pos-bez { width: 55mm; }
|
||||||
|
.pos-menge { width: 18mm; text-align: right; }
|
||||||
|
.pos-einheit { width: 15mm; }
|
||||||
|
.pos-ust { width: 12mm; text-align: right; }
|
||||||
|
.pos-preis { width: 20mm; text-align: right; }
|
||||||
|
.pos-gesamt { width: 22mm; text-align: right; }
|
||||||
|
|
||||||
|
/* Summen */
|
||||||
|
.sum-table { float: right; width: 70mm; border-collapse: collapse; margin-bottom: 8mm; font-size: 9.5pt; }
|
||||||
|
.sum-table td { padding: 1.5mm 2mm; }
|
||||||
|
.sum-table .sum-val { text-align: right; border-top: 0.3pt solid #ccc; }
|
||||||
|
.sum-table .sum-total td { border-top: 1pt solid #000; font-size: 10.5pt; }
|
||||||
|
|
||||||
|
/* Zahlungshinweis */
|
||||||
|
.payment-note { clear: both; margin-top: 5mm; font-size: 9pt; padding: 3mm; background: #f8f8f8; border-left: 2pt solid #2563eb; }
|
||||||
|
|
||||||
|
/* Fußzeile */
|
||||||
|
.footer-table { width: 100%; border-collapse: collapse; font-size: 7.5pt; border-top: 0.5pt solid #ccc; padding-top: 2mm; }
|
||||||
|
.footer-table td { width: 25%; padding: 1mm 2mm; vertical-align: top; }
|
||||||
|
.page-nr { text-align: right; }
|
||||||
|
CSS;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/Services/UstvaCalculator.php
Normal file
107
src/Services/UstvaCalculator.php
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram\Services;
|
||||||
|
|
||||||
|
use Steuerkram\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet alle ELSTER-Kennzahlen (Kz) für die Umsatzsteuervoranmeldung.
|
||||||
|
*
|
||||||
|
* Maßgeblich sind ausschließlich ust_satz und betrag_ust der Buchung selbst –
|
||||||
|
* nicht der steuercode des gebuchten Kontos. Dadurch werden Buchungen mit
|
||||||
|
* ust_satz=0 (z.B. innergemeinschaftliche Erwerbe, steuerfreie Leistungen)
|
||||||
|
* korrekt mit 0 Vorsteuer/USt gewertet.
|
||||||
|
*
|
||||||
|
* Buchungslogik:
|
||||||
|
* Umsatzsteuer (Kz 81/86): ust_satz > 0 auf Ertrag-Haben-Konten
|
||||||
|
* Vorsteuer (Kz 61/62): ust_satz > 0 auf Aufwand/Aktiv-Soll-Konten
|
||||||
|
* Steuerfreie Umsätze (Kz 35): Ertrag-Konten mit ust_satz = 0
|
||||||
|
*/
|
||||||
|
class UstvaCalculator
|
||||||
|
{
|
||||||
|
public function calculate(int $year, int $month): array
|
||||||
|
{
|
||||||
|
$kz = [
|
||||||
|
'81' => ['name' => 'Steuerpfl. Umsätze 19 %', 'betrag' => 0.0, 'steuer' => 0.0],
|
||||||
|
'86' => ['name' => 'Steuerpfl. Umsätze 7 %', 'betrag' => 0.0, 'steuer' => 0.0],
|
||||||
|
'35' => ['name' => 'Steuerfreie Umsätze (ohne Vorsteuerabzug)', 'betrag' => 0.0, 'steuer' => null],
|
||||||
|
'41' => ['name' => 'Steuerfreie EU-Lieferungen (§ 4 Nr. 1b)', 'betrag' => 0.0, 'steuer' => null],
|
||||||
|
'83' => ['name' => 'USt auf steuerpfl. Umsätze 19 %', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
'84' => ['name' => 'USt auf steuerpfl. Umsätze 7 %', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
'61' => ['name' => 'Vorsteuer aus Eingangsrechnungen 19 %', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
'62' => ['name' => 'Vorsteuer aus Eingangsrechnungen 7 %', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
'66' => ['name' => 'Vorsteuerbeträge gesamt', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
'69' => ['name' => 'Verbleibende USt-Vorauszahlung/Erstattung', 'betrag' => null, 'steuer' => 0.0],
|
||||||
|
];
|
||||||
|
|
||||||
|
$bookings = $this->fetchBookings($year, $month);
|
||||||
|
|
||||||
|
foreach ($bookings as $b) {
|
||||||
|
$ustSatz = (float)$b['ust_satz'];
|
||||||
|
$betragUst = abs((float)$b['betrag_ust']);
|
||||||
|
$betragNetto= abs((float)$b['betrag_netto']);
|
||||||
|
$habenArt = $b['haben_kontenart'] ?? '';
|
||||||
|
$sollArt = $b['soll_kontenart'] ?? '';
|
||||||
|
|
||||||
|
// ── Umsatzsteuer (Ausgangsseite) ──────────────────────────────
|
||||||
|
// Kriterium: Haben-Konto = Ertrag UND ust_satz > 0
|
||||||
|
if ($habenArt === 'Ertrag' && $ustSatz > 0) {
|
||||||
|
if (abs($ustSatz - 19) < 0.01) {
|
||||||
|
$kz['81']['betrag'] += $betragNetto;
|
||||||
|
} elseif (abs($ustSatz - 7) < 0.01) {
|
||||||
|
$kz['86']['betrag'] += $betragNetto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steuerfreie Umsätze: Haben-Konto = Ertrag UND ust_satz = 0
|
||||||
|
if ($habenArt === 'Ertrag' && $ustSatz == 0) {
|
||||||
|
$kz['35']['betrag'] += $betragNetto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vorsteuer (Eingangsseite) ──────────────────────────────────
|
||||||
|
// Kriterium: Soll-Konto = Aufwand oder Aktiv (Vorsteuerkonto)
|
||||||
|
// UND ust_satz > 0 UND betrag_ust > 0
|
||||||
|
// Buchungen mit ust_satz=0 (z.B. innergemeinschaftlich) → kein Eintrag
|
||||||
|
if (in_array($sollArt, ['Aufwand', 'Aktiv']) && $ustSatz > 0 && $betragUst > 0) {
|
||||||
|
if (abs($ustSatz - 19) < 0.01) {
|
||||||
|
$kz['61']['steuer'] += $betragUst;
|
||||||
|
} elseif (abs($ustSatz - 7) < 0.01) {
|
||||||
|
$kz['62']['steuer'] += $betragUst;
|
||||||
|
}
|
||||||
|
$kz['66']['steuer'] += $betragUst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abgeleitete Kennzahlen
|
||||||
|
$kz['83']['steuer'] = round($kz['81']['betrag'] * 0.19, 2);
|
||||||
|
$kz['84']['steuer'] = round($kz['86']['betrag'] * 0.07, 2);
|
||||||
|
|
||||||
|
$ustGesamt = $kz['83']['steuer'] + $kz['84']['steuer'];
|
||||||
|
$kz['69']['steuer'] = round($ustGesamt - $kz['66']['steuer'], 2);
|
||||||
|
|
||||||
|
foreach ($kz as &$field) {
|
||||||
|
if ($field['betrag'] !== null) $field['betrag'] = round($field['betrag'], 2);
|
||||||
|
if ($field['steuer'] !== null) $field['steuer'] = round($field['steuer'], 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $kz;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchBookings(int $year, int $month): array
|
||||||
|
{
|
||||||
|
return Database::fetchAll(
|
||||||
|
'SELECT bk.ust_satz, bk.betrag_netto, bk.betrag_ust,
|
||||||
|
s.kontenart AS soll_kontenart,
|
||||||
|
h.kontenart AS haben_kontenart
|
||||||
|
FROM bookings bk
|
||||||
|
JOIN accounts s ON bk.soll_konto_id = s.id
|
||||||
|
JOIN accounts h ON bk.haben_konto_id = h.id
|
||||||
|
JOIN fiscal_years fy ON bk.fiscal_year_id = fy.id
|
||||||
|
WHERE fy.year = ? AND MONTH(bk.buchungs_datum) = ?
|
||||||
|
AND bk.is_reversal = 0',
|
||||||
|
[$year, $month]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/View.php
Normal file
63
src/View.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Steuerkram;
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
private string $viewDir;
|
||||||
|
private array $flash = [];
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->viewDir = dirname(__DIR__) . '/src/Views';
|
||||||
|
|
||||||
|
// Session-based flash messages
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if (isset($_SESSION['flash'])) {
|
||||||
|
$this->flash = $_SESSION['flash'];
|
||||||
|
unset($_SESSION['flash']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(string $template, array $data = []): void
|
||||||
|
{
|
||||||
|
$data['flash'] = $this->flash;
|
||||||
|
$data['currentPath'] = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
||||||
|
|
||||||
|
extract($data, EXTR_SKIP);
|
||||||
|
include $this->viewDir . '/' . $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function redirect(string $path, string $message = '', string $type = 'success'): void
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start();
|
||||||
|
}
|
||||||
|
if ($message !== '') {
|
||||||
|
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||||||
|
}
|
||||||
|
header('Location: ' . $path);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function h(mixed $value): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatMoney(mixed $value): string
|
||||||
|
{
|
||||||
|
return number_format((float)$value, 2, ',', '.') . ' €';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function formatDate(?string $date): string
|
||||||
|
{
|
||||||
|
if (!$date) return '–';
|
||||||
|
$d = \DateTime::createFromFormat('Y-m-d', $date);
|
||||||
|
return $d ? $d->format('d.m.Y') : $date;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/Views/accounts/form.php
Normal file
91
src/Views/accounts/form.php
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:700px">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
<input type="hidden" name="_back" value="<?= $h($back ?? '/accounts') ?>">
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="kontonummer">Kontonummer *</label>
|
||||||
|
<input type="text" id="kontonummer" name="kontonummer"
|
||||||
|
value="<?= $h($account['kontonummer'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['kontonummer']) ? 'input-error' : '' ?>"
|
||||||
|
required>
|
||||||
|
<?php if (isset($errors['kontonummer'])): ?>
|
||||||
|
<div class="error-text"><?= $h($errors['kontonummer']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="kontenart">Kontenart *</label>
|
||||||
|
<select id="kontenart" name="kontenart"
|
||||||
|
class="<?= isset($errors['kontenart']) ? 'input-error' : '' ?>" required>
|
||||||
|
<option value="">– wählen –</option>
|
||||||
|
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
|
||||||
|
<option value="<?= $h($art) ?>"<?= ($account['kontenart'] ?? '') === $art ? ' selected' : '' ?>>
|
||||||
|
<?= $h($art) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if (isset($errors['kontenart'])): ?>
|
||||||
|
<div class="error-text"><?= $h($errors['kontenart']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="bezeichnung">Bezeichnung *</label>
|
||||||
|
<input type="text" id="bezeichnung" name="bezeichnung"
|
||||||
|
value="<?= $h($account['bezeichnung'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['bezeichnung']) ? 'input-error' : '' ?>"
|
||||||
|
required>
|
||||||
|
<?php if (isset($errors['bezeichnung'])): ?>
|
||||||
|
<div class="error-text"><?= $h($errors['bezeichnung']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="steuercode">Steuercode</label>
|
||||||
|
<select id="steuercode" name="steuercode">
|
||||||
|
<option value="">– kein –</option>
|
||||||
|
<?php foreach (['USt19','USt7','VSt19','VSt7'] as $sc): ?>
|
||||||
|
<option value="<?= $sc ?>"<?= ($account['steuercode'] ?? '') === $sc ? ' selected' : '' ?>>
|
||||||
|
<?= $sc ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">USt = Ausgangs-USt, VSt = Vorsteuer</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ustva_kz">UStVA Kennzahl (ELSTER)</label>
|
||||||
|
<input type="text" id="ustva_kz" name="ustva_kz"
|
||||||
|
value="<?= $h($account['ustva_kz'] ?? '') ?>"
|
||||||
|
placeholder="z.B. 81, 86, 66">
|
||||||
|
<div class="form-hint">Kennzahl im ELSTER-Formular</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="beschreibung">Beschreibung</label>
|
||||||
|
<textarea id="beschreibung" name="beschreibung"><?= $h($account['beschreibung'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($account['id'])): ?>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="aktiv" value="1"<?= ($account['aktiv'] ?? 1) ? ' checked' : '' ?>>
|
||||||
|
Konto aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem;margin-top:1.5rem">
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
<a href="<?= $h($back ?? '/accounts') ?>" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
76
src/Views/accounts/list.php
Normal file
76
src/Views/accounts/list.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<input type="search" name="search" placeholder="Suche Kto.-Nr. oder Bezeichnung…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
|
||||||
|
<select name="kontenart">
|
||||||
|
<option value="">Alle Kontenarten</option>
|
||||||
|
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
|
||||||
|
<option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||||
|
<?php if (!empty($filter['search']) || !empty($filter['kontenart'])): ?>
|
||||||
|
<a href="/accounts" class="btn btn-secondary">✕ Reset</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span class="text-muted text-sm"><?= count($accounts) ?> Konten</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kontonummer</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Kontenart</th>
|
||||||
|
<th>Steuercode</th>
|
||||||
|
<th>UStVA Kz</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($accounts as $acc): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="td-mono font-bold"><?= $h($acc['kontonummer']) ?></td>
|
||||||
|
<td><?= $h($acc['bezeichnung']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php
|
||||||
|
$badgeClass = match($acc['kontenart']) {
|
||||||
|
'Ertrag' => 'badge-green',
|
||||||
|
'Aufwand' => 'badge-red',
|
||||||
|
'Aktiv' => 'badge-blue',
|
||||||
|
'Passiv' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="td-muted"><?= $h($acc['steuercode'] ?? '–') ?></td>
|
||||||
|
<td class="td-muted"><?= $h($acc['ustva_kz'] ?? '–') ?></td>
|
||||||
|
<td class="td-muted text-sm"><?= $h(mb_strimwidth($acc['beschreibung'] ?? '', 0, 60, '…')) ?></td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<a href="/accounts/<?= $h($acc['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
|
||||||
|
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
|
||||||
|
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($accounts)): ?>
|
||||||
|
<tr><td colspan="7" class="text-center text-muted" style="padding:2rem">Keine Konten gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
338
src/Views/belege/detail.php
Normal file
338
src/Views/belege/detail.php
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
use Steuerkram\Models\Setting;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
$testMode = Setting::isTestMode();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Beleg-Info-Bar -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body" style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;padding:.75rem 1.25rem">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Belegnr.</span><br>
|
||||||
|
<strong class="font-mono"><?= $h($beleg['beleg_nr']) ?></strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Typ</span><br>
|
||||||
|
<span class="badge badge-gray"><?= $h($beleg['belegtyp']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Belegdatum</span><br>
|
||||||
|
<?= View::formatDate($beleg['beleg_datum']) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Eingangsdatum</span><br>
|
||||||
|
<?= View::formatDate($beleg['eingangs_datum']) ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Rechnungsnr.</span><br>
|
||||||
|
<?= $beleg['rechnungs_nr'] ? $h($beleg['rechnungs_nr']) : '<span class="text-muted">–</span>' ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">Brutto lt. Beleg</span><br>
|
||||||
|
<?= $beleg['betrag_brutto'] ? '<strong>' . $fmt($beleg['betrag_brutto']) . '</strong>' : '<span class="text-muted">–</span>' ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-sm">GoBD-Integrität</span><br>
|
||||||
|
<?php if ($integrity): ?>
|
||||||
|
<span class="badge badge-green">✓ OK</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-red">⚠ Prüfsumme abweichend</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:.5rem">
|
||||||
|
<a href="/belege/<?= $h($beleg['id']) ?>/file" target="_blank" class="btn btn-secondary btn-sm">Datei öffnen</a>
|
||||||
|
<?php if (!$beleg['locked']): ?>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="document.getElementById('beleg-meta-form').style.display = document.getElementById('beleg-meta-form').style.display === 'none' ? 'block' : 'none'">
|
||||||
|
Metadaten bearbeiten
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!$beleg['locked'] && empty($bookings)): ?>
|
||||||
|
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/delete"
|
||||||
|
data-confirm="Beleg wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$beleg['locked']): ?>
|
||||||
|
<div id="beleg-meta-form" style="display:none;border-top:1px solid var(--color-border);padding:.75rem 1.25rem">
|
||||||
|
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/meta">
|
||||||
|
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:flex-end">
|
||||||
|
<div class="form-group" style="margin:0;min-width:140px">
|
||||||
|
<label style="font-size:.8rem">Rechnungsnr.</label>
|
||||||
|
<input type="text" name="rechnungs_nr" value="<?= $h($beleg['rechnungs_nr'] ?? '') ?>"
|
||||||
|
placeholder="z. B. RE-2024-001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label style="font-size:.8rem">Belegtyp</label>
|
||||||
|
<select name="belegtyp">
|
||||||
|
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
|
||||||
|
<option value="<?= $t ?>"<?= $beleg['belegtyp'] === $t ? ' selected' : '' ?>><?= $t ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0">
|
||||||
|
<label style="font-size:.8rem">Belegdatum</label>
|
||||||
|
<input type="date" name="beleg_datum" value="<?= $h($beleg['beleg_datum'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin:0;min-width:120px">
|
||||||
|
<label style="font-size:.8rem">Brutto lt. Beleg (€)</label>
|
||||||
|
<input type="number" name="betrag_brutto" step="0.01" min="0"
|
||||||
|
value="<?= $h($beleg['betrag_brutto'] ?? '') ?>" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Speichern</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm"
|
||||||
|
onclick="document.getElementById('beleg-meta-form').style.display='none'">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split-Pane: Vorschau + Buchungsformular -->
|
||||||
|
<div class="beleg-layout">
|
||||||
|
|
||||||
|
<!-- Linke Seite: Dokumentvorschau -->
|
||||||
|
<div class="beleg-preview">
|
||||||
|
<?php if (in_array($beleg['mime_type'], ['application/pdf'])): ?>
|
||||||
|
<iframe src="/belege/<?= $h($beleg['id']) ?>/file" title="Belegvorschau"></iframe>
|
||||||
|
<?php elseif (str_starts_with($beleg['mime_type'], 'image/')): ?>
|
||||||
|
<img src="/belege/<?= $h($beleg['id']) ?>/file" alt="Belegvorschau" style="object-fit:contain;max-height:700px;width:100%">
|
||||||
|
<?php elseif (str_contains($beleg['mime_type'], 'xml')): ?>
|
||||||
|
<div style="padding:1.5rem">
|
||||||
|
<div class="badge badge-blue mb-2">XML / eRechnung</div>
|
||||||
|
<p class="text-muted text-sm">XML-Datei kann nicht direkt angezeigt werden.</p>
|
||||||
|
<a href="/belege/<?= $h($beleg['id']) ?>/file" class="btn btn-secondary mt-2" download>XML herunterladen</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechte Seite: Buchungen -->
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<!-- Vorhandene Buchungen -->
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Buchungszeilen (<?= count($bookings) ?>)</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Buchungsnr.</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Soll</th>
|
||||||
|
<th>Haben</th>
|
||||||
|
<th class="td-right">Netto</th>
|
||||||
|
<th class="td-right">USt</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
<th>Text</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($bookings as $bk):
|
||||||
|
$isReversed = !empty($bk['reversed_by']);
|
||||||
|
$isReversal = (bool)$bk['is_reversal'];
|
||||||
|
?>
|
||||||
|
<tr style="<?= ($isReversed || $isReversal) ? 'opacity:.55;background:#fff5f5' : '' ?>">
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['buchungs_nr']) ?></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['soll_nr']) ?><br><span class="td-muted"><?= $h($bk['soll_name']) ?></span></td>
|
||||||
|
<td class="td-mono text-sm"><?= $h($bk['haben_nr']) ?><br><span class="td-muted"><?= $h($bk['haben_name']) ?></span></td>
|
||||||
|
<td class="td-right td-mono"><?= $fmt($bk['betrag_netto']) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
|
||||||
|
<td class="td-right td-mono"><?= $fmt($bk['betrag_brutto']) ?></td>
|
||||||
|
<td class="text-sm">
|
||||||
|
<?= $h($bk['buchungstext']) ?>
|
||||||
|
<?php if ($isReversal): ?>
|
||||||
|
<span class="badge badge-red">Storno</span>
|
||||||
|
<?php elseif ($isReversed): ?>
|
||||||
|
<span class="badge badge-red">Storniert</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if (!$bk['locked'] && !$isReversal && !$isReversed): ?>
|
||||||
|
<div style="display:flex;gap:.35rem">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm"
|
||||||
|
data-modal-open="modal-storno-<?= $h($bk['id']) ?>">
|
||||||
|
Storno
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<?php if ($testMode): ?>
|
||||||
|
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
|
||||||
|
data-confirm="Buchung <?= $h($bk['buchungs_nr']) ?> wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)">
|
||||||
|
<input type="hidden" name="beleg_id" value="<?= $h($beleg['id']) ?>">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" title="Löschen (Testmodus)">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storno-Modal -->
|
||||||
|
<div class="modal-overlay" id="modal-storno-<?= $h($bk['id']) ?>">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">Buchung stornieren</div>
|
||||||
|
<p class="text-sm text-muted mb-2">Die Buchung <?= $h($bk['buchungs_nr']) ?> wird durch eine Gegenbuchung storniert (GoBD-konform).</p>
|
||||||
|
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/reverse">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Stornogrund</label>
|
||||||
|
<input type="text" name="reason" required placeholder="Grund für die Stornierung">
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-danger">Stornieren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($bookings)): ?>
|
||||||
|
<tr><td colspan="9" class="text-center text-muted" style="padding:1rem">Noch keine Buchungen.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Neue Buchungszeile erfassen -->
|
||||||
|
<?php if (!$beleg['locked']): ?>
|
||||||
|
<?php
|
||||||
|
$formErrors = $formErrors ?? [];
|
||||||
|
$formData = $formData ?? [];
|
||||||
|
?>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Neue Buchungszeile</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($formErrors)): ?>
|
||||||
|
<div class="flash flash-error" style="margin-bottom:1rem">
|
||||||
|
<?php if (isset($formErrors['_exception'])): ?>
|
||||||
|
Fehler: <?= $h($formErrors['_exception']) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<?= implode(' | ', array_map(fn($k,$v) => "<strong>$k:</strong> $v", array_keys($formErrors), $formErrors)) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="/belege/<?= $h($beleg['id']) ?>/bookings">
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Buchungsdatum *</label>
|
||||||
|
<input type="date" name="buchungs_datum"
|
||||||
|
value="<?= $h($formData['buchungs_datum'] ?? date('Y-m-d')) ?>" required
|
||||||
|
class="<?= isset($formErrors['buchungs_datum']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>USt-Satz (%)</label>
|
||||||
|
<select name="ust_satz">
|
||||||
|
<?php foreach (['0','7','19'] as $s): ?>
|
||||||
|
<option value="<?= $s ?>"<?= ($formData['ust_satz'] ?? '19') === $s ? ' selected' : '' ?>><?= $s ?> %</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Konten nach Art gruppieren für <optgroup>
|
||||||
|
$kontenGruppen = [];
|
||||||
|
foreach ($accounts as $acc) {
|
||||||
|
$kontenGruppen[$acc['kontenart']][] = $acc;
|
||||||
|
}
|
||||||
|
$gruppenReihenfolge = ['Aufwand', 'Ertrag', 'Aktiv', 'Passiv', 'Neutral'];
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Soll-Konto -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="soll_konto_id">Soll-Konto *
|
||||||
|
<span id="soll_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
|
||||||
|
</label>
|
||||||
|
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
|
||||||
|
data-target="soll_konto_id" autocomplete="off" style="margin-bottom:.3rem">
|
||||||
|
<select id="soll_konto_id" name="soll_konto_id" required size="6"
|
||||||
|
onchange="showKontoArt('soll_konto_id','soll_konto_art',this)">
|
||||||
|
<option value="">– Konto wählen –</option>
|
||||||
|
<?php foreach ($gruppenReihenfolge as $art): ?>
|
||||||
|
<?php if (empty($kontenGruppen[$art])) continue; ?>
|
||||||
|
<optgroup label="── <?= $h($art) ?> ──">
|
||||||
|
<?php foreach ($kontenGruppen[$art] as $acc): ?>
|
||||||
|
<option value="<?= $h($acc['id']) ?>"
|
||||||
|
data-art="<?= $h($acc['kontenart']) ?>"
|
||||||
|
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
|
||||||
|
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</optgroup>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">Typisch: <strong>Aufwand</strong>-Konto (Eingangsrechnung) oder <strong>Aktiv</strong>-Konto (Forderung/Bank)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Haben-Konto -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="haben_konto_id">Haben-Konto *
|
||||||
|
<span id="haben_konto_art" class="badge badge-gray" style="margin-left:.5rem;font-size:.7rem"></span>
|
||||||
|
</label>
|
||||||
|
<input type="search" placeholder="Kontonr. oder Bezeichnung filtern…" class="konto-filter"
|
||||||
|
data-target="haben_konto_id" autocomplete="off" style="margin-bottom:.3rem">
|
||||||
|
<select id="haben_konto_id" name="haben_konto_id" required size="6"
|
||||||
|
onchange="showKontoArt('haben_konto_id','haben_konto_art',this)">
|
||||||
|
<option value="">– Konto wählen –</option>
|
||||||
|
<?php foreach ($gruppenReihenfolge as $art): ?>
|
||||||
|
<?php if (empty($kontenGruppen[$art])) continue; ?>
|
||||||
|
<optgroup label="── <?= $h($art) ?> ──">
|
||||||
|
<?php foreach ($kontenGruppen[$art] as $acc): ?>
|
||||||
|
<option value="<?= $h($acc['id']) ?>"
|
||||||
|
data-art="<?= $h($acc['kontenart']) ?>"
|
||||||
|
data-search="<?= $h($acc['kontonummer'] . ' ' . $acc['bezeichnung'] . ' ' . $acc['kontenart']) ?>">
|
||||||
|
<?= $h($acc['kontonummer']) ?> – <?= $h($acc['bezeichnung']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</optgroup>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<div class="form-hint">Typisch: <strong>Passiv</strong>-Konto (Verbindlichkeit) oder <strong>Ertrag</strong>-Konto (Ausgangsrechnung)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nettobetrag (€) *</label>
|
||||||
|
<input type="number" name="betrag_netto" step="0.01" min="0.01" required
|
||||||
|
placeholder="0.00"
|
||||||
|
value="<?= $h($formData['betrag_netto'] ?? '') ?>"
|
||||||
|
class="<?= isset($formErrors['betrag_netto']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Buchungstext *</label>
|
||||||
|
<?php
|
||||||
|
$defaultText = $formData['buchungstext']
|
||||||
|
?? ($beleg['rechnungs_nr']
|
||||||
|
? 'Rechnung ' . $beleg['rechnungs_nr']
|
||||||
|
: $beleg['belegtyp'] . ' ' . $beleg['beleg_nr']);
|
||||||
|
?>
|
||||||
|
<input type="text" name="buchungstext" required
|
||||||
|
value="<?= $h($defaultText) ?>"
|
||||||
|
class="<?= isset($formErrors['buchungstext']) ? 'input-error' : '' ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary">Buchung erfassen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="flash flash-info">Dieser Beleg ist gesperrt (GoBD). Keine weiteren Buchungen möglich.</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
94
src/Views/belege/list.php
Normal file
94
src/Views/belege/list.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="search" name="search" placeholder="Belegnr., Dateiname, Rechnungsnr.…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
|
||||||
|
<select name="year">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
<?php for ($y = (int)date('Y'); $y >= 2020; $y--): ?>
|
||||||
|
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<select name="month">
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
|
||||||
|
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="booked">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="0"<?= ($filter['booked'] ?? '') === '0' ? ' selected' : '' ?>>Ungebucht</option>
|
||||||
|
<option value="1"<?= ($filter['booked'] ?? '') === '1' ? ' selected' : '' ?>>Gebucht</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||||
|
<a href="/belege" class="btn btn-secondary">✕</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span class="text-muted text-sm"><?= $total ?> Belege</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Belegnr.</th>
|
||||||
|
<th>Eingangsdatum</th>
|
||||||
|
<th>Belegdatum</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Rechnungsnr.</th>
|
||||||
|
<th>Dateiname</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $b): ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/belege/<?= $h($b['id']) ?>" class="font-mono"><?= $h($b['beleg_nr']) ?></a></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($b['eingangs_datum']) ?></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($b['beleg_datum']) ?></td>
|
||||||
|
<td><span class="badge badge-gray"><?= $h($b['belegtyp']) ?></span></td>
|
||||||
|
<td class="td-muted"><?= $h($b['rechnungs_nr'] ?? '–') ?></td>
|
||||||
|
<td class="text-sm text-muted"><?= $h(mb_strimwidth($b['filename'], 0, 35, '…')) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= $b['betrag_brutto'] !== null ? View::formatMoney($b['betrag_brutto']) : '–' ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($b['locked']): ?>
|
||||||
|
<span class="badge badge-purple">Gesperrt</span>
|
||||||
|
<?php elseif ($b['booked']): ?>
|
||||||
|
<span class="badge badge-green">Gebucht</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-yellow">Offen</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/belege/<?= $h($b['id']) ?>" class="btn btn-secondary btn-sm">Öffnen</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<tr><td colspan="9" class="text-center text-muted" style="padding:2rem">Keine Belege gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<div class="pagination">
|
||||||
|
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
|
||||||
|
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
|
||||||
|
<?php if ($p === $page): ?>
|
||||||
|
<span class="active"><?= $p ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
67
src/Views/belege/upload.php
Normal file
67
src/Views/belege/upload.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:700px">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/belege/upload" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="beleg_file">Datei auswählen *</label>
|
||||||
|
<input type="file" id="beleg_file" name="beleg_file" required
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
|
||||||
|
class="<?= isset($errors['file']) ? 'input-error' : '' ?>">
|
||||||
|
<div class="form-hint">Erlaubte Formate: PDF, PNG, JPEG, TIFF, XML (eRechnung)</div>
|
||||||
|
<?php if (isset($errors['file'])): ?>
|
||||||
|
<div class="error-text"><?= $h($errors['file']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="belegtyp">Belegtyp</label>
|
||||||
|
<select id="belegtyp" name="belegtyp">
|
||||||
|
<?php foreach (['Eingangsrechnung','Ausgangsrechnung','Quittung','Kontoauszug','Sonstiges'] as $t): ?>
|
||||||
|
<option value="<?= $t ?>"<?= ($old['belegtyp'] ?? 'Eingangsrechnung') === $t ? ' selected' : '' ?>><?= $t ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="eingangs_datum">Eingangsdatum *</label>
|
||||||
|
<input type="date" id="eingangs_datum" name="eingangs_datum"
|
||||||
|
value="<?= $h($old['eingangs_datum'] ?? date('Y-m-d')) ?>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="beleg_datum">Belegdatum (Rechnungsdatum)</label>
|
||||||
|
<input type="date" id="beleg_datum" name="beleg_datum"
|
||||||
|
value="<?= $h($old['beleg_datum'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rechnungs_nr">Rechnungsnummer des Lieferanten</label>
|
||||||
|
<input type="text" id="rechnungs_nr" name="rechnungs_nr"
|
||||||
|
value="<?= $h($old['rechnungs_nr'] ?? '') ?>" placeholder="z.B. RE-2024-001">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="betrag_brutto">Bruttobetrag (€)</label>
|
||||||
|
<input type="number" id="betrag_brutto" name="betrag_brutto"
|
||||||
|
value="<?= $h($old['betrag_brutto'] ?? '') ?>"
|
||||||
|
step="0.01" min="0" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notizen</label>
|
||||||
|
<textarea id="notes" name="notes" rows="3"><?= $h($old['notes'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem;margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-primary">Hochladen</button>
|
||||||
|
<a href="/belege" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
112
src/Views/bookings/journal.php
Normal file
112
src/Views/bookings/journal.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="search" name="search" placeholder="Buchungstext suchen…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:220px">
|
||||||
|
<select name="year">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
<?php foreach ($years as $y): ?>
|
||||||
|
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="month">
|
||||||
|
<option value="">Alle Monate</option>
|
||||||
|
<?php foreach (['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] as $i => $m): ?>
|
||||||
|
<option value="<?= $i+1 ?>"<?= ($filter['month'] ?? '') == $i+1 ? ' selected' : '' ?>><?= $m ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="konto" style="width:160px">
|
||||||
|
<option value="">Alle Konten</option>
|
||||||
|
<?php foreach ($accounts as $a): ?>
|
||||||
|
<option value="<?= $a['id'] ?>"<?= ($filter['konto'] ?? '') == $a['id'] ? ' selected' : '' ?>>
|
||||||
|
<?= $h($a['kontonummer']) ?> – <?= $h($a['bezeichnung']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||||
|
<a href="/journal" class="btn btn-secondary">✕</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span class="text-muted text-sm"><?= $total ?> Buchungen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Buchungsnr.</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Belegnr.</th>
|
||||||
|
<th>Soll-Konto</th>
|
||||||
|
<th>Haben-Konto</th>
|
||||||
|
<th class="td-right">Netto</th>
|
||||||
|
<th class="td-right">USt (%)</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
<th>Buchungstext</th>
|
||||||
|
<?php if ($testMode): ?><th></th><?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $bk):
|
||||||
|
$isReversed = !empty($bk['reversed_by']);
|
||||||
|
$isReversal = (bool)$bk['is_reversal'];
|
||||||
|
$isCancelled = $isReversed || $isReversal;
|
||||||
|
?>
|
||||||
|
<tr style="<?= $isCancelled ? 'opacity:.55;background:#fff5f5' : '' ?>">
|
||||||
|
<td class="td-mono text-sm" style="<?= $isReversed ? 'text-decoration:line-through' : '' ?>">
|
||||||
|
<?= $h($bk['buchungs_nr']) ?>
|
||||||
|
</td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($bk['buchungs_datum']) ?></td>
|
||||||
|
<td><a href="/belege/<?= $h($bk['beleg_id']) ?>" class="td-mono text-sm"><?= $h($bk['beleg_nr']) ?></a></td>
|
||||||
|
<td class="td-mono text-sm">
|
||||||
|
<?= $h($bk['soll_nr']) ?><br>
|
||||||
|
<span class="td-muted"><?= $h(mb_strimwidth($bk['soll_name'], 0, 25, '…')) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="td-mono text-sm">
|
||||||
|
<?= $h($bk['haben_nr']) ?><br>
|
||||||
|
<span class="td-muted"><?= $h(mb_strimwidth($bk['haben_name'], 0, 25, '…')) ?></span>
|
||||||
|
</td>
|
||||||
|
<td class="td-right td-mono"><?= View::formatMoney($bk['betrag_netto']) ?></td>
|
||||||
|
<td class="td-right td-muted"><?= number_format((float)$bk['ust_satz'], 0) ?> %</td>
|
||||||
|
<td class="td-right td-mono"><?= View::formatMoney($bk['betrag_brutto']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?= $h($bk['buchungstext']) ?>
|
||||||
|
<?php if ($isReversal): ?>
|
||||||
|
<span class="badge badge-red">Storno</span>
|
||||||
|
<?php elseif ($isReversed): ?>
|
||||||
|
<span class="badge badge-red">Storniert</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<?php if ($testMode): ?>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/bookings/<?= $h($bk['id']) ?>/delete"
|
||||||
|
data-confirm="Buchung <?= $h($bk['buchungs_nr']) ?> wirklich löschen? (Nur im Testmodus – nicht GoBD-konform!)">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm" title="Löschen (Testmodus)">✕</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<tr><td colspan="<?= $testMode ? 10 : 9 ?>" class="text-center text-muted" style="padding:2rem">Keine Buchungen gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<div class="pagination">
|
||||||
|
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
|
||||||
|
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
|
||||||
|
<?php if ($p === $page): ?><span class="active"><?= $p ?></span>
|
||||||
|
<?php else: ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a><?php endif; ?>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
97
src/Views/customers/form.php
Normal file
97
src/Views/customers/form.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:800px">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="anrede">Anrede</label>
|
||||||
|
<select id="anrede" name="anrede">
|
||||||
|
<option value="">–</option>
|
||||||
|
<?php foreach (['Herr','Frau','Divers','Dr.','Prof.'] as $a): ?>
|
||||||
|
<option value="<?= $a ?>"<?= ($customer['anrede'] ?? '') === $a ? ' selected' : '' ?>><?= $a ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="vorname">Vorname</label>
|
||||||
|
<input type="text" id="vorname" name="vorname" value="<?= $h($customer['vorname'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nachname">Nachname *</label>
|
||||||
|
<input type="text" id="nachname" name="nachname" value="<?= $h($customer['nachname'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['nachname']) ? 'input-error' : '' ?>" required>
|
||||||
|
<?php if (isset($errors['nachname'])): ?><div class="error-text"><?= $h($errors['nachname']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="firma">Firma / Organisation</label>
|
||||||
|
<input type="text" id="firma" name="firma" value="<?= $h($customer['firma'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="strasse">Straße + Hausnummer *</label>
|
||||||
|
<input type="text" id="strasse" name="strasse" value="<?= $h($customer['strasse'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['strasse']) ? 'input-error' : '' ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:grid;grid-template-columns:100px 1fr;gap:.5rem">
|
||||||
|
<div>
|
||||||
|
<label for="plz">PLZ *</label>
|
||||||
|
<input type="text" id="plz" name="plz" value="<?= $h($customer['plz'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="ort">Ort *</label>
|
||||||
|
<input type="text" id="ort" name="ort" value="<?= $h($customer['ort'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">E-Mail</label>
|
||||||
|
<input type="email" id="email" name="email" value="<?= $h($customer['email'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="telefon">Telefon</label>
|
||||||
|
<input type="tel" id="telefon" name="telefon" value="<?= $h($customer['telefon'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ust_id">Umsatzsteuer-ID</label>
|
||||||
|
<input type="text" id="ust_id" name="ust_id" value="<?= $h($customer['ust_id'] ?? '') ?>"
|
||||||
|
placeholder="DE123456789">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="steuernummer">Steuernummer</label>
|
||||||
|
<input type="text" id="steuernummer" name="steuernummer" value="<?= $h($customer['steuernummer'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="payment_days">Zahlungsziel (Tage)</label>
|
||||||
|
<input type="number" id="payment_days" name="payment_days"
|
||||||
|
value="<?= $h($customer['payment_days'] ?? 14) ?>" min="0" max="365">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Notizen</label>
|
||||||
|
<textarea id="notes" name="notes"><?= $h($customer['notes'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem;margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
<a href="/customers" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
<?php if (!empty($customer['id']) && !$customer['archived']): ?>
|
||||||
|
<form method="post" action="/customers/<?= $h($customer['id']) ?>/archive" style="margin:0"
|
||||||
|
data-confirm="Kunden archivieren?">
|
||||||
|
<button type="submit" class="btn btn-secondary">Archivieren</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
60
src/Views/customers/list.php
Normal file
60
src/Views/customers/list.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;align-items:center">
|
||||||
|
<input type="search" name="search" placeholder="Name, Kundennummer, E-Mail…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:260px">
|
||||||
|
<label style="display:flex;align-items:center;gap:.4rem;font-size:.875rem;text-transform:none;letter-spacing:0">
|
||||||
|
<input type="checkbox" name="archived" value="1"<?= !empty($filter['archived']) ? ' checked' : '' ?>>
|
||||||
|
Archivierte anzeigen
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-secondary">Suchen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<span class="text-muted text-sm"><?= count($customers) ?> Kunden</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kundennr.</th>
|
||||||
|
<th>Name / Firma</th>
|
||||||
|
<th>Adresse</th>
|
||||||
|
<th>E-Mail</th>
|
||||||
|
<th>USt-ID</th>
|
||||||
|
<th>Zahlungsziel</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($customers as $c): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="td-mono"><?= $h($c['kundennummer']) ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="/customers/<?= $h($c['id']) ?>"><?= $h(\Steuerkram\Models\Customer::displayName($c)) ?></a>
|
||||||
|
<?php if ($c['archived']): ?><span class="badge badge-gray">Archiviert</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="td-muted text-sm"><?= $h($c['plz']) ?> <?= $h($c['ort']) ?></td>
|
||||||
|
<td class="td-muted text-sm"><?= $h($c['email'] ?? '–') ?></td>
|
||||||
|
<td class="td-muted text-sm"><?= $h($c['ust_id'] ?? '–') ?></td>
|
||||||
|
<td class="td-muted"><?= $h($c['payment_days']) ?> Tage</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<a href="/customers/<?= $h($c['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
|
||||||
|
<a href="/invoices/new?customer_id=<?= $h($c['id']) ?>" class="btn btn-primary btn-sm">Rechnung</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($customers)): ?>
|
||||||
|
<tr><td colspan="7" class="text-center text-muted" style="padding:2rem">Keine Kunden gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
58
src/Views/customers/show.php
Normal file
58
src/Views/customers/show.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:340px 1fr;gap:1.5rem;align-items:start">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Kundendaten</div>
|
||||||
|
<a href="/customers/<?= $h($customer['id']) ?>/edit" class="btn btn-secondary btn-sm">Bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.875rem">
|
||||||
|
<?php $row = fn($label, $val) => "<tr><td style='color:var(--color-muted);padding:.35rem 0;width:120px'>{$label}</td><td>{$val}</td></tr>"; ?>
|
||||||
|
<?= $row('Kundennr.', $h($customer['kundennummer'])) ?>
|
||||||
|
<?= $row('Name', $h(\Steuerkram\Models\Customer::displayName($customer))) ?>
|
||||||
|
<?php if ($customer['firma']): ?><?= $row('Firma', $h($customer['firma'])) ?><?php endif; ?>
|
||||||
|
<?= $row('Adresse', $h($customer['strasse']) . '<br>' . $h($customer['plz'] . ' ' . $customer['ort'])) ?>
|
||||||
|
<?= $row('E-Mail', $customer['email'] ? '<a href="mailto:'.$h($customer['email']).'">'.$h($customer['email']).'</a>' : '–') ?>
|
||||||
|
<?= $row('Telefon', $h($customer['telefon'] ?? '–')) ?>
|
||||||
|
<?= $row('USt-ID', $h($customer['ust_id'] ?? '–')) ?>
|
||||||
|
<?= $row('Zahlungsziel', $h($customer['payment_days']) . ' Tage') ?>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Rechnungen</div>
|
||||||
|
<a href="/invoices/new?customer_id=<?= $h($customer['id']) ?>" class="btn btn-primary btn-sm">+ Neue Rechnung</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rechnungsnr.</th><th>Datum</th><th>Betreff</th><th class="td-right">Brutto</th><th>Status</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($invoices as $inv): ?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/invoices/<?= $h($inv['id']) ?>"><?= $h($inv['rechnungs_nr'] ?? 'Entwurf') ?></a></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($inv['rechnungs_datum']) ?></td>
|
||||||
|
<td><?= $h(mb_strimwidth($inv['betreff'], 0, 40, '…')) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_brutto']) ?></td>
|
||||||
|
<td><?php
|
||||||
|
$bc = match($inv['status']) {
|
||||||
|
'Bezahlt' => 'badge-green',
|
||||||
|
'Storniert' => 'badge-red',
|
||||||
|
'Gesendet' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
?><span class="badge <?= $bc ?>"><?= $h($inv['status']) ?></span></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($invoices)): ?>
|
||||||
|
<tr><td colspan="5" class="text-center text-muted" style="padding:1.5rem">Noch keine Rechnungen.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
177
src/Views/dashboard.php
Normal file
177
src/Views/dashboard.php
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Umsatz <?= $h($stats['year']) ?></div>
|
||||||
|
<div class="stat-value" style="color:var(--color-success)"><?= $fmt($stats['umsatz']) ?></div>
|
||||||
|
<div class="stat-meta">Betriebseinnahmen (netto)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Ausgaben <?= $h($stats['year']) ?></div>
|
||||||
|
<div class="stat-value" style="color:var(--color-danger)"><?= $fmt($stats['ausgaben']) ?></div>
|
||||||
|
<div class="stat-meta">Betriebsausgaben (netto)</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Gewinn / Verlust <?= $h($stats['year']) ?></div>
|
||||||
|
<div class="stat-value" style="color:<?= $stats['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>">
|
||||||
|
<?= $fmt($stats['gewinn']) ?>
|
||||||
|
</div>
|
||||||
|
<div class="stat-meta">EÜR-Ergebnis</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Offene Rechnungen</div>
|
||||||
|
<div class="stat-value"><?= $h($stats['offene_rechnungen']) ?></div>
|
||||||
|
<div class="stat-meta"><?= $fmt($stats['offene_rechnungen_sum']) ?> ausstehend</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Ungebuchte Belege</div>
|
||||||
|
<div class="stat-value" style="color:<?= $stats['ungebuchte_belege'] > 0 ? 'var(--color-warning)' : 'inherit' ?>">
|
||||||
|
<?= $h($stats['ungebuchte_belege']) ?>
|
||||||
|
</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<?php if ($stats['ungebuchte_belege'] > 0): ?>
|
||||||
|
<a href="/belege?booked=0">Belege buchen →</a>
|
||||||
|
<?php else: ?>
|
||||||
|
Alle Belege gebucht
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drag & Drop Upload -->
|
||||||
|
<div id="dnd-zone" style="
|
||||||
|
border:2px dashed var(--color-border);
|
||||||
|
border-radius:var(--radius);
|
||||||
|
background:var(--color-surface);
|
||||||
|
padding:2rem;
|
||||||
|
text-align:center;
|
||||||
|
margin-bottom:1.5rem;
|
||||||
|
cursor:pointer;
|
||||||
|
transition:border-color .15s, background .15s;
|
||||||
|
position:relative;
|
||||||
|
">
|
||||||
|
<input type="file" id="dnd-file-input" accept=".pdf,.png,.jpg,.jpeg,.tif,.tiff,.xml"
|
||||||
|
style="position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%">
|
||||||
|
<div id="dnd-icon" style="font-size:2rem;line-height:1;margin-bottom:.5rem;pointer-events:none">📎</div>
|
||||||
|
<div style="font-weight:600;pointer-events:none">Beleg hierher ziehen oder klicken</div>
|
||||||
|
<div class="text-muted text-sm" style="pointer-events:none">PDF, PNG, JPEG, TIFF, XML · max. 20 MB</div>
|
||||||
|
<div id="dnd-status" style="margin-top:.75rem;min-height:1.5rem;pointer-events:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schnellzugriff -->
|
||||||
|
<div style="display:flex;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap">
|
||||||
|
<a href="/belege/upload" class="btn btn-primary">+ Beleg hochladen</a>
|
||||||
|
<a href="/invoices/new" class="btn btn-secondary">+ Rechnung erstellen</a>
|
||||||
|
<a href="/tax/ustva" class="btn btn-secondary">UStVA erstellen</a>
|
||||||
|
<a href="/tax/euer/<?= date('Y') ?>" class="btn btn-secondary">EÜR <?= date('Y') ?></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const zone = document.getElementById('dnd-zone');
|
||||||
|
const input = document.getElementById('dnd-file-input');
|
||||||
|
const status = document.getElementById('dnd-status');
|
||||||
|
const icon = document.getElementById('dnd-icon');
|
||||||
|
|
||||||
|
function setActive(on) {
|
||||||
|
zone.style.borderColor = on ? 'var(--color-primary)' : 'var(--color-border)';
|
||||||
|
zone.style.background = on ? '#eff6ff' : 'var(--color-surface)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, color) {
|
||||||
|
status.innerHTML = `<span style="color:${color || 'var(--color-muted)'}">${msg}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.addEventListener('dragover', (e) => { e.preventDefault(); setActive(true); });
|
||||||
|
zone.addEventListener('dragleave', (e) => { if (!zone.contains(e.relatedTarget)) setActive(false); });
|
||||||
|
zone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setActive(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) uploadFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
if (input.files[0]) uploadFile(input.files[0]);
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function uploadFile(file) {
|
||||||
|
const allowed = ['application/pdf','image/png','image/jpeg','image/tiff',
|
||||||
|
'application/xml','text/xml'];
|
||||||
|
if (!allowed.includes(file.type) && !file.name.match(/\.(pdf|png|jpe?g|tif{1,2}|xml)$/i)) {
|
||||||
|
setStatus('Nicht unterstütztes Dateiformat.', 'var(--color-danger)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
icon.textContent = '⏳';
|
||||||
|
setStatus('Wird hochgeladen…');
|
||||||
|
zone.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('beleg_file', file);
|
||||||
|
fd.append('belegtyp', 'Eingangsrechnung');
|
||||||
|
fd.append('eingangs_datum', new Date().toISOString().slice(0,10));
|
||||||
|
fd.append('beleg_datum', new Date().toISOString().slice(0,10));
|
||||||
|
|
||||||
|
fetch('/belege/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Unbekannter Fehler');
|
||||||
|
icon.textContent = '✅';
|
||||||
|
setStatus(`${data.beleg_nr} hochgeladen – weiterleiten…`, 'var(--color-success)');
|
||||||
|
setTimeout(() => { window.location.href = '/belege/' + data.id; }, 600);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
icon.textContent = '📎';
|
||||||
|
zone.style.pointerEvents = '';
|
||||||
|
setStatus('Fehler: ' + err.message, 'var(--color-danger)');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Letzte Buchungen -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Letzte Buchungen</div>
|
||||||
|
<a href="/journal" class="btn btn-secondary btn-sm">Alle anzeigen →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Belegnr.</th>
|
||||||
|
<th>Soll</th>
|
||||||
|
<th>Haben</th>
|
||||||
|
<th>Buchungstext</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($recentBookings as $b): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="td-muted"><?= View::formatDate($b['buchungs_datum']) ?></td>
|
||||||
|
<td><a href="/belege/<?= $h($b['beleg_id']) ?>"><?= $h($b['beleg_nr']) ?></a></td>
|
||||||
|
<td class="td-mono"><?= $h($b['soll_nr']) ?> <span class="text-muted">– <?= $h($b['soll_name']) ?></span></td>
|
||||||
|
<td class="td-mono"><?= $h($b['haben_nr']) ?> <span class="text-muted">– <?= $h($b['haben_name']) ?></span></td>
|
||||||
|
<td><?= $h($b['buchungstext']) ?><?php if ($b['is_reversal']): ?> <span class="badge badge-red">Storno</span><?php endif; ?></td>
|
||||||
|
<td class="td-right td-mono"><?= $fmt($b['betrag_brutto']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($recentBookings)): ?>
|
||||||
|
<tr><td colspan="6" class="text-center text-muted" style="padding:2rem">Noch keine Buchungen vorhanden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
152
src/Views/invoices/form.php
Normal file
152
src/Views/invoices/form.php
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$isEdit = !empty($invoice['id']);
|
||||||
|
// Pre-select customer if passed via GET
|
||||||
|
$preCustomer = (int)($_GET['customer_id'] ?? 0);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="<?= $h($action) ?>">
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-2 mb-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="customer_id">Kunde *</label>
|
||||||
|
<select id="customer_id" name="customer_id"
|
||||||
|
class="<?= isset($errors['customer_id']) ? 'input-error' : '' ?>" required
|
||||||
|
onchange="updatePaymentDays(this)">
|
||||||
|
<option value="">– Kunde wählen –</option>
|
||||||
|
<?php foreach ($customers as $c): ?>
|
||||||
|
<?php $selected = ($invoice['customer_id'] ?? $preCustomer) == $c['id'] ? ' selected' : ''; ?>
|
||||||
|
<option value="<?= $c['id'] ?>"<?= $selected ?>
|
||||||
|
data-payment="<?= $c['payment_days'] ?>">
|
||||||
|
<?= $h($c['kundennummer']) ?> – <?= $h(\Steuerkram\Models\Customer::displayName($c)) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<?php if (isset($errors['customer_id'])): ?><div class="error-text"><?= $h($errors['customer_id']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="betreff">Betreff *</label>
|
||||||
|
<input type="text" id="betreff" name="betreff"
|
||||||
|
value="<?= $h($invoice['betreff'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['betreff']) ? 'input-error' : '' ?>" required>
|
||||||
|
<?php if (isset($errors['betreff'])): ?><div class="error-text"><?= $h($errors['betreff']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid form-grid-4 mb-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rechnungs_datum">Rechnungsdatum *</label>
|
||||||
|
<input type="date" id="rechnungs_datum" name="rechnungs_datum"
|
||||||
|
value="<?= $h($invoice['rechnungs_datum'] ?? date('Y-m-d')) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="leistungszeitraum_von">Leistungszeitraum von</label>
|
||||||
|
<input type="date" id="leistungszeitraum_von" name="leistungszeitraum_von"
|
||||||
|
value="<?= $h($invoice['leistungszeitraum_von'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="leistungszeitraum_bis">Leistungszeitraum bis</label>
|
||||||
|
<input type="date" id="leistungszeitraum_bis" name="leistungszeitraum_bis"
|
||||||
|
value="<?= $h($invoice['leistungszeitraum_bis'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="payment_days">Zahlungsziel (Tage)</label>
|
||||||
|
<input type="number" id="payment_days" name="payment_days"
|
||||||
|
value="<?= $h($invoice['payment_days'] ?? $settings['payment_days_default'] ?? 14) ?>"
|
||||||
|
min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Positionen-Tabelle -->
|
||||||
|
<div class="mb-2">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem">
|
||||||
|
<strong>Rechnungspositionen</strong>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="addInvoiceLine()">+ Position hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (isset($errors['lines'])): ?>
|
||||||
|
<div class="flash flash-error mb-2"><?= $h($errors['lines']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:40px">Pos.</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th style="width:80px">Menge</th>
|
||||||
|
<th style="width:80px">Einheit</th>
|
||||||
|
<th style="width:80px">USt.</th>
|
||||||
|
<th style="width:110px">Einzelpreis</th>
|
||||||
|
<th style="width:100px" class="td-right">Netto</th>
|
||||||
|
<th style="width:110px" class="td-right">Brutto</th>
|
||||||
|
<th style="width:40px"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="invoice-lines-body">
|
||||||
|
<?php foreach ($lines as $i => $line): ?>
|
||||||
|
<tr class="invoice-line-row">
|
||||||
|
<td><span class="line-pos text-muted"><?= $i+1 ?></span></td>
|
||||||
|
<td><input type="text" name="lines[<?= $i ?>][bezeichnung]" value="<?= $h($line['bezeichnung']) ?>" required style="min-width:200px"></td>
|
||||||
|
<td><input type="number" name="lines[<?= $i ?>][menge]" value="<?= $h($line['menge']) ?>" min="0.001" step="0.001" style="width:75px" required></td>
|
||||||
|
<td>
|
||||||
|
<select name="lines[<?= $i ?>][einheit]" style="width:75px">
|
||||||
|
<?php foreach (['Stk.','h','Tage','Monat','Pauschale','km'] as $eu): ?>
|
||||||
|
<option value="<?= $eu ?>"<?= $line['einheit'] === $eu ? ' selected' : '' ?>><?= $eu ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select name="lines[<?= $i ?>][ust_satz]" style="width:75px">
|
||||||
|
<?php foreach ([19, 7, 0] as $r): ?>
|
||||||
|
<option value="<?= $r ?>"<?= (float)$line['ust_satz'] === (float)$r ? ' selected' : '' ?>><?= $r ?> %</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" name="lines[<?= $i ?>][einzelpreis]" value="<?= $h($line['einzelpreis']) ?>" step="0.01" min="0" style="width:105px" required></td>
|
||||||
|
<td class="td-right line-netto text-muted"><?= View::formatMoney($line['betrag_netto']) ?></td>
|
||||||
|
<td class="td-right line-brutto font-bold"><?= View::formatMoney($line['betrag_brutto']) ?></td>
|
||||||
|
<td><button type="button" class="btn btn-danger btn-sm" onclick="removeInvoiceLine(this)">✕</button></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summen -->
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-top:.75rem">
|
||||||
|
<table style="width:280px;font-size:.9rem">
|
||||||
|
<tr><td>Nettobetrag:</td><td class="td-right" id="total-netto">–</td></tr>
|
||||||
|
<tr><td>zzgl. Umsatzsteuer:</td><td class="td-right" id="total-ust">–</td></tr>
|
||||||
|
<tr style="font-size:1rem;font-weight:700">
|
||||||
|
<td>Rechnungsbetrag:</td>
|
||||||
|
<td class="td-right" id="total-brutto">–</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="notes">Interne Notizen</label>
|
||||||
|
<textarea id="notes" name="notes" rows="2"><?= $h($invoice['notes'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem;margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-primary">Entwurf speichern</button>
|
||||||
|
<a href="/invoices" class="btn btn-secondary">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updatePaymentDays(sel) {
|
||||||
|
const opt = sel.options[sel.selectedIndex];
|
||||||
|
const days = opt.dataset.payment;
|
||||||
|
if (days) document.getElementById('payment_days').value = days;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
88
src/Views/invoices/list.php
Normal file
88
src/Views/invoices/list.php
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="search" name="search" placeholder="Rechnungsnr., Betreff, Kunde…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:240px">
|
||||||
|
<select name="status">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
<?php foreach (['Entwurf','Gesendet','Bezahlt','Storniert'] as $s): ?>
|
||||||
|
<option value="<?= $s ?>"<?= ($filter['status'] ?? '') === $s ? ' selected' : '' ?>><?= $s ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<select name="year">
|
||||||
|
<option value="">Alle Jahre</option>
|
||||||
|
<?php for ($y = (int)date('Y'); $y >= 2020; $y--): ?>
|
||||||
|
<option value="<?= $y ?>"<?= ($filter['year'] ?? '') == $y ? ' selected' : '' ?>><?= $y ?></option>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary">Filtern</button>
|
||||||
|
<a href="/invoices" class="btn btn-secondary">✕</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right"><span class="text-muted text-sm"><?= $total ?> Rechnungen</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rechnungsnr.</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Betreff</th>
|
||||||
|
<th class="td-right">Netto</th>
|
||||||
|
<th class="td-right">Brutto</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $inv): ?>
|
||||||
|
<?php
|
||||||
|
$badgeClass = match($inv['status']) {
|
||||||
|
'Bezahlt' => 'badge-green',
|
||||||
|
'Storniert' => 'badge-red',
|
||||||
|
'Gesendet' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
$customerName = !empty($inv['firma']) ? $inv['firma'] : trim(($inv['vorname'] ?? '') . ' ' . $inv['nachname']);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><a href="/invoices/<?= $h($inv['id']) ?>" class="td-mono"><?= $h($inv['rechnungs_nr'] ?? 'Entwurf') ?></a></td>
|
||||||
|
<td class="td-muted"><?= View::formatDate($inv['rechnungs_datum']) ?></td>
|
||||||
|
<td><a href="/customers/<?= $h($inv['customer_id']) ?>"><?= $h($customerName) ?></a></td>
|
||||||
|
<td><?= $h(mb_strimwidth($inv['betreff'], 0, 40, '…')) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_netto']) ?></td>
|
||||||
|
<td class="td-right td-mono"><?= View::formatMoney($inv['betrag_brutto']) ?></td>
|
||||||
|
<td><span class="badge <?= $badgeClass ?>"><?= $h($inv['status']) ?></span></td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:.4rem">
|
||||||
|
<a href="/invoices/<?= $h($inv['id']) ?>" class="btn btn-secondary btn-sm">Öffnen</a>
|
||||||
|
<?php if ($inv['status'] !== 'Entwurf'): ?>
|
||||||
|
<a href="/invoices/<?= $h($inv['id']) ?>/pdf" class="btn btn-secondary btn-sm">PDF</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($rows)): ?>
|
||||||
|
<tr><td colspan="8" class="text-center text-muted" style="padding:2rem">Keine Rechnungen gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($pages > 1): ?>
|
||||||
|
<div class="pagination">
|
||||||
|
<?php if ($page > 1): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page-1])) ?>">‹</a><?php endif; ?>
|
||||||
|
<?php for ($p = max(1,$page-3); $p <= min($pages,$page+3); $p++): ?>
|
||||||
|
<?php if ($p === $page): ?><span class="active"><?= $p ?></span>
|
||||||
|
<?php else: ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $p])) ?>"><?= $p ?></a><?php endif; ?>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<?php if ($page < $pages): ?><a href="?<?= http_build_query(array_merge($filter, ['page' => $page+1])) ?>">›</a><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
186
src/Views/invoices/show.php
Normal file
186
src/Views/invoices/show.php
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
|
||||||
|
$customerName = !empty($invoice['firma'])
|
||||||
|
? $invoice['firma']
|
||||||
|
: trim(($invoice['anrede'] ?? '') . ' ' . ($invoice['vorname'] ?? '') . ' ' . $invoice['nachname']);
|
||||||
|
|
||||||
|
$lz = '';
|
||||||
|
if (!empty($invoice['leistungszeitraum_von'])) {
|
||||||
|
$lz = View::formatDate($invoice['leistungszeitraum_von']);
|
||||||
|
if (!empty($invoice['leistungszeitraum_bis'])) {
|
||||||
|
$lz .= ' – ' . View::formatDate($invoice['leistungszeitraum_bis']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$badgeClass = match($invoice['status']) {
|
||||||
|
'Bezahlt' => 'badge-green',
|
||||||
|
'Storniert' => 'badge-red',
|
||||||
|
'Gesendet' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<!-- Aktionsleiste -->
|
||||||
|
<div class="toolbar mb-2">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<span class="badge <?= $badgeClass ?>" style="font-size:.9rem;padding:.3rem .8rem"><?= $h($invoice['status']) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right" style="gap:.5rem">
|
||||||
|
<?php if ($invoice['status'] === 'Entwurf'): ?>
|
||||||
|
<a href="/invoices/<?= $h($invoice['id']) ?>/edit" class="btn btn-secondary">Bearbeiten</a>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/finalize"
|
||||||
|
data-confirm="Rechnung finalisieren? Danach kann sie nicht mehr bearbeitet werden.">
|
||||||
|
<button type="submit" class="btn btn-primary">Finalisieren & Nummerieren</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($invoice['status'] !== 'Entwurf'): ?>
|
||||||
|
<a href="/invoices/<?= $h($invoice['id']) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($invoice['status'] === 'Gesendet' && empty($invoice['beleg_id'])): ?>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/book"
|
||||||
|
data-confirm="Rechnung jetzt buchen?">
|
||||||
|
<button type="submit" class="btn btn-success">Buchen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($invoice['status'] === 'Gesendet'): ?>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/mark-paid">
|
||||||
|
<button type="submit" class="btn btn-success">Als bezahlt markieren</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (in_array($invoice['status'], ['Entwurf','Gesendet'])): ?>
|
||||||
|
<form method="post" action="/invoices/<?= $h($invoice['id']) ?>/cancel"
|
||||||
|
data-confirm="Rechnung stornieren?">
|
||||||
|
<button type="submit" class="btn btn-danger">Stornieren</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechnungsvorschau (DIN 5008 ähnlich) -->
|
||||||
|
<div class="invoice-preview">
|
||||||
|
|
||||||
|
<!-- Logo -->
|
||||||
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
|
<div style="text-align:center;margin-bottom:1.5rem">
|
||||||
|
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:60px;max-width:250px">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Adressbereich -->
|
||||||
|
<div style="display:grid;grid-template-columns:100mm 1fr;gap:1rem;margin-bottom:1.5rem">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:8pt;color:#666;border-bottom:1px solid #ccc;padding-bottom:2px;margin-bottom:4px">
|
||||||
|
<?= $h($settings['firma'] ?? '') ?> · <?= $h($settings['strasse'] ?? '') ?> · <?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?>
|
||||||
|
</div>
|
||||||
|
<div style="line-height:1.5">
|
||||||
|
<?= $h($customerName) ?><br>
|
||||||
|
<?= $h($invoice['strasse']) ?><br>
|
||||||
|
<?= $h($invoice['plz']) ?> <?= $h($invoice['ort']) ?>
|
||||||
|
<?php if ($invoice['land'] !== 'DE'): ?><br><?= $h($invoice['land']) ?><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:left;font-size:.85rem;line-height:1.7">
|
||||||
|
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
||||||
|
<?= $h($settings['inhaber'] ?? '') ?><br>
|
||||||
|
<?= $h($settings['strasse'] ?? '') ?><br>
|
||||||
|
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
|
||||||
|
<?= $h($settings['telefon'] ?? '') ?><br>
|
||||||
|
<?= $h($settings['email'] ?? '') ?><br>
|
||||||
|
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta-Tabelle -->
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#f0f0f0">
|
||||||
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Betreff</th>
|
||||||
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Rechnungsnummer</th>
|
||||||
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Kundennummer</th>
|
||||||
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Leistungszeitraum</th>
|
||||||
|
<th style="padding:.4rem .5rem;text-align:left;border:1px solid #ddd;font-size:.8rem">Datum</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['betreff']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;border:1px solid #ddd;font-weight:700"><?= $h($invoice['rechnungs_nr'] ?? 'Entwurf') ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($invoice['kundennummer']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= $h($lz ?: '–') ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;border:1px solid #ddd"><?= View::formatDate($invoice['rechnungs_datum']) ?></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Positionen -->
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:1.5rem;font-size:.9rem">
|
||||||
|
<thead>
|
||||||
|
<tr style="background:#1e293b;color:#fff">
|
||||||
|
<th style="padding:.5rem;text-align:left;width:40px">Pos.</th>
|
||||||
|
<th style="padding:.5rem;text-align:left">Bezeichnung</th>
|
||||||
|
<th style="padding:.5rem;text-align:right;width:70px">Menge</th>
|
||||||
|
<th style="padding:.5rem;text-align:left;width:70px">Einheit</th>
|
||||||
|
<th style="padding:.5rem;text-align:right;width:60px">USt.</th>
|
||||||
|
<th style="padding:.5rem;text-align:right;width:100px">Einzelpreis</th>
|
||||||
|
<th style="padding:.5rem;text-align:right;width:110px">Gesamt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($lines as $line): ?>
|
||||||
|
<tr style="border-bottom:1px solid #eee">
|
||||||
|
<td style="padding:.4rem .5rem"><?= $h($line['position']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem"><?= $h($line['bezeichnung']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['menge'], 3, ',', '.') ?></td>
|
||||||
|
<td style="padding:.4rem .5rem"><?= $h($line['einheit']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;text-align:right"><?= number_format((float)$line['ust_satz'], 0) ?> %</td>
|
||||||
|
<td style="padding:.4rem .5rem;text-align:right"><?= $fmt($line['einzelpreis']) ?></td>
|
||||||
|
<td style="padding:.4rem .5rem;text-align:right;font-weight:600"><?= $fmt($line['betrag_brutto']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Summen -->
|
||||||
|
<div style="display:flex;justify-content:flex-end;margin-bottom:1.5rem">
|
||||||
|
<table style="width:260px">
|
||||||
|
<tr><td style="padding:.3rem 0">Nettobetrag:</td><td style="text-align:right"><?= $fmt($invoice['betrag_netto']) ?></td></tr>
|
||||||
|
<tr><td style="padding:.3rem 0">zzgl. Umsatzsteuer:</td><td style="text-align:right"><?= $fmt($invoice['betrag_ust']) ?></td></tr>
|
||||||
|
<tr style="font-weight:700;font-size:1.05rem;border-top:2px solid #000">
|
||||||
|
<td style="padding:.5rem 0">Rechnungsbetrag:</td>
|
||||||
|
<td style="text-align:right"><?= $fmt($invoice['betrag_brutto']) ?></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zahlungshinweis -->
|
||||||
|
<div style="background:#f0f4ff;border-left:3px solid #2563eb;padding:.75rem 1rem;margin-bottom:2rem;font-size:.9rem">
|
||||||
|
Bitte überweisen Sie den Rechnungsbetrag innerhalb <?= $h($invoice['payment_days'] ?? 14) ?> Tagen auf das unten genannte Konto.
|
||||||
|
<strong>Fällig am: <?= View::formatDate($invoice['faellig_datum']) ?></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fußzeile -->
|
||||||
|
<div style="border-top:1px solid #ccc;padding-top:.75rem;display:grid;grid-template-columns:1fr 1fr 1fr 80px;gap:1rem;font-size:.75rem;color:#555">
|
||||||
|
<div>
|
||||||
|
<strong><?= $h($settings['firma'] ?? '') ?></strong><br>
|
||||||
|
<?= $h($settings['inhaber'] ?? '') ?><br>
|
||||||
|
<?= $h($settings['strasse'] ?? '') ?><br>
|
||||||
|
<?= $h(($settings['plz'] ?? '') . ' ' . ($settings['ort'] ?? '')) ?><br>
|
||||||
|
USt-ID: <?= $h($settings['ust_id'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Tel: <?= $h($settings['telefon'] ?? '') ?><br>
|
||||||
|
E-Mail: <?= $h($settings['email'] ?? '') ?><br>
|
||||||
|
Web: <?= $h($settings['website'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<?= $h($settings['bank_name'] ?? '') ?><br>
|
||||||
|
IBAN: <?= $h($settings['iban'] ?? '') ?><br>
|
||||||
|
BIC: <?= $h($settings['bic'] ?? '') ?>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">Seite 1 von 1</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
8
src/Views/layout/404.php
Normal file
8
src/Views/layout/404.php
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?php
|
||||||
|
$pageTitle = '404 – Seite nicht gefunden';
|
||||||
|
$content = '<div class="card card-body text-center mt-3">
|
||||||
|
<h2 style="font-size:3rem;color:var(--color-muted);margin-bottom:.5rem">404</h2>
|
||||||
|
<p class="text-muted mb-2">Die Seite <strong>' . \Steuerkram\View::h($uri ?? '') . '</strong> wurde nicht gefunden.</p>
|
||||||
|
<a href="/dashboard" class="btn btn-primary">Zum Dashboard</a>
|
||||||
|
</div>';
|
||||||
|
include __DIR__ . '/base.php';
|
||||||
41
src/Views/layout/base.php
Normal file
41
src/Views/layout/base.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><?= \Steuerkram\View::h($pageTitle ?? 'Steuerkram') ?> – Steuerkram</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-layout">
|
||||||
|
|
||||||
|
<?php include __DIR__ . '/nav.php'; ?>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-title"><?= \Steuerkram\View::h($pageTitle ?? '') ?></div>
|
||||||
|
<div style="display:flex;gap:.5rem;align-items:center;">
|
||||||
|
<?php if (!empty($topbarActions)) echo $topbarActions; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-body">
|
||||||
|
|
||||||
|
<?php if (!empty($flash)): ?>
|
||||||
|
<div class="flash-list">
|
||||||
|
<?php foreach ($flash as $f): ?>
|
||||||
|
<div class="flash flash-<?= \Steuerkram\View::h($f['type']) ?>">
|
||||||
|
<?= \Steuerkram\View::h($f['message']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?= $content ?? '' ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
72
src/Views/layout/nav.php
Normal file
72
src/Views/layout/nav.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
$path = $currentPath ?? '/';
|
||||||
|
function navActive(string $prefix, string $current): string {
|
||||||
|
return str_starts_with($current, $prefix) ? ' active' : '';
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebar-brand">Steuer<span>kram</span></div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-label">Übersicht</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/dashboard" class="<?= navActive('/dashboard', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-label">Belege & Buchungen</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/belege" class="<?= navActive('/belege', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14,2 14,8 20,8"/></svg>
|
||||||
|
Belege
|
||||||
|
</a>
|
||||||
|
<a href="/journal" class="<?= navActive('/journal', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
|
||||||
|
Buchungsjournal
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-label">Ausgangsrechnungen</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/invoices" class="<?= navActive('/invoices', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>
|
||||||
|
Rechnungen
|
||||||
|
</a>
|
||||||
|
<a href="/customers" class="<?= navActive('/customers', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
Kunden
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-label">Steuerauswertungen</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/tax/ustva" class="<?= navActive('/tax/ustva', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 14l6-6"/><circle cx="9.5" cy="9.5" r=".5" fill="currentColor"/><circle cx="14.5" cy="14.5" r=".5" fill="currentColor"/><path d="M21 12c0 4.97-4.03 9-9 9s-9-4.03-9-9 4.03-9 9-9 9 4.03 9 9z"/></svg>
|
||||||
|
UStVA
|
||||||
|
</a>
|
||||||
|
<a href="/tax/euer" class="<?= navActive('/tax/euer', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
|
||||||
|
EÜR
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-label">Verwaltung</div>
|
||||||
|
<nav>
|
||||||
|
|
||||||
|
<a href="/settings" class="<?= navActive('/settings', $path) ?>">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M4.93 4.93l1.41 1.41M19.07 19.07l-1.41-1.41M4.93 19.07l1.41-1.41M12 2v2M12 20v2M2 12h2M20 12h2"/></svg>
|
||||||
|
Einstellungen
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
299
src/Views/settings/form.php
Normal file
299
src/Views/settings/form.php
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<form method="post" action="/settings">
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
<!-- Firmendaten -->
|
||||||
|
<div>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Firmendaten</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Firma / Name *</label>
|
||||||
|
<input type="text" name="firma" value="<?= $h($settings['firma'] ?? '') ?>"
|
||||||
|
class="<?= isset($errors['firma']) ? 'input-error' : '' ?>" required>
|
||||||
|
<?php if (isset($errors['firma'])): ?><div class="error-text"><?= $h($errors['firma']) ?></div><?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Inhaber</label>
|
||||||
|
<input type="text" name="inhaber" value="<?= $h($settings['inhaber'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Straße *</label>
|
||||||
|
<input type="text" name="strasse" value="<?= $h($settings['strasse'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:100px 1fr;gap:.5rem">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>PLZ *</label>
|
||||||
|
<input type="text" name="plz" value="<?= $h($settings['plz'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ort *</label>
|
||||||
|
<input type="text" name="ort" value="<?= $h($settings['ort'] ?? '') ?>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Telefon</label>
|
||||||
|
<input type="tel" name="telefon" value="<?= $h($settings['telefon'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>E-Mail</label>
|
||||||
|
<input type="email" name="email" value="<?= $h($settings['email'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Website</label>
|
||||||
|
<input type="url" name="website" value="<?= $h($settings['website'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Steuerdaten</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Umsatzsteuer-ID</label>
|
||||||
|
<input type="text" name="ust_id" value="<?= $h($settings['ust_id'] ?? '') ?>" placeholder="DE123456789">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Steuernummer (Finanzamt)</label>
|
||||||
|
<input type="text" name="steuernummer" value="<?= $h($settings['steuernummer'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Finanzamt</label>
|
||||||
|
<input type="text" name="finanzamt" value="<?= $h($settings['finanzamt'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ELSTER-Steuernummer (13-stellig, Format: FFFBBBBBBUUUUP)</label>
|
||||||
|
<input type="text" name="ustva_steuernummer" value="<?= $h($settings['ustva_steuernummer'] ?? '') ?>"
|
||||||
|
placeholder="z.B. 2893012345678">
|
||||||
|
<div class="form-hint">Wird für die ELSTER XML-Datei benötigt</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bundesland (für ELSTER)</label>
|
||||||
|
<select name="ustva_bundesland">
|
||||||
|
<option value="">– wählen –</option>
|
||||||
|
<?php foreach (['BW'=>'Baden-Württemberg','BY'=>'Bayern','BE'=>'Berlin','BB'=>'Brandenburg','HB'=>'Bremen','HH'=>'Hamburg','HE'=>'Hessen','MV'=>'Mecklenburg-Vorpommern','NI'=>'Niedersachsen','NW'=>'Nordrhein-Westfalen','RP'=>'Rheinland-Pfalz','SL'=>'Saarland','SN'=>'Sachsen','ST'=>'Sachsen-Anhalt','SH'=>'Schleswig-Holstein','TH'=>'Thüringen'] as $code => $name): ?>
|
||||||
|
<option value="<?= $code ?>"<?= ($settings['ustva_bundesland'] ?? '') === $code ? ' selected' : '' ?>><?= $name ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bankdaten + Logo + Nummernkreise -->
|
||||||
|
<div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg w-full" style="margin-bottom:1rem">Einstellungen speichern</button>
|
||||||
|
|
||||||
|
<!-- Betriebsmodus -->
|
||||||
|
<div class="card mb-2" style="border:2px solid <?= ($settings['test_mode'] ?? 1) ? 'var(--color-warning,#f59e0b)' : 'var(--color-success,#22c55e)' ?>">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Betriebsmodus</div>
|
||||||
|
<?php if ($settings['test_mode'] ?? 1): ?>
|
||||||
|
<span class="badge badge-yellow">Testinstallation</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-green">Produktiv</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label style="display:flex;align-items:center;gap:.75rem;cursor:pointer;user-select:none">
|
||||||
|
<div style="position:relative;width:48px;height:26px;flex-shrink:0">
|
||||||
|
<input type="checkbox" name="test_mode" id="test_mode_toggle"
|
||||||
|
<?= ($settings['test_mode'] ?? 1) ? 'checked' : '' ?>
|
||||||
|
style="opacity:0;width:0;height:0;position:absolute"
|
||||||
|
onchange="updateModeLabel(this)">
|
||||||
|
<span id="toggle_track" style="
|
||||||
|
position:absolute;inset:0;border-radius:13px;transition:background .2s;
|
||||||
|
background:<?= ($settings['test_mode'] ?? 1) ? '#f59e0b' : '#22c55e' ?>;
|
||||||
|
"></span>
|
||||||
|
<span id="toggle_thumb" style="
|
||||||
|
position:absolute;top:3px;width:20px;height:20px;border-radius:50%;
|
||||||
|
background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3);
|
||||||
|
left:<?= ($settings['test_mode'] ?? 1) ? '3px' : '25px' ?>;
|
||||||
|
"></span>
|
||||||
|
</div>
|
||||||
|
<span id="mode_label" style="font-weight:600">
|
||||||
|
<?= ($settings['test_mode'] ?? 1) ? 'Testinstallation (Löschen von Buchungen erlaubt)' : 'Produktivbetrieb (GoBD-strikt)' ?>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-hint mt-1" id="mode_hint" style="color:<?= ($settings['test_mode'] ?? 1) ? '#92400e' : '#166534' ?>">
|
||||||
|
<?php if ($settings['test_mode'] ?? 1): ?>
|
||||||
|
Im Testmodus können Buchungen und Belege direkt gelöscht werden. Nicht GoBD-konform.
|
||||||
|
<?php else: ?>
|
||||||
|
Im Produktivmodus sind Buchungen unveränderlich. Korrekturen nur per Stornobuchung moeglich.
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Bankdaten</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Bankname</label>
|
||||||
|
<input type="text" name="bank_name" value="<?= $h($settings['bank_name'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IBAN</label>
|
||||||
|
<input type="text" name="iban" value="<?= $h($settings['iban'] ?? '') ?>" placeholder="DE89 3704 0044 0532 0130 00">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>BIC</label>
|
||||||
|
<input type="text" name="bic" value="<?= $h($settings['bic'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Logo</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php if (!empty($settings['logo_path'])): ?>
|
||||||
|
<div style="text-align:center;margin-bottom:1rem">
|
||||||
|
<img src="/<?= $h($settings['logo_path']) ?>" alt="Logo" style="max-height:80px;max-width:200px">
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/settings/logo/delete" style="margin-bottom:1rem">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm w-full">Logo löschen</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="post" action="/settings/logo" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Logo hochladen (PNG, JPG, SVG)</label>
|
||||||
|
<input type="file" name="logo" accept=".png,.jpg,.jpeg,.svg">
|
||||||
|
<div class="form-hint">Empfohlen: transparentes PNG, mind. 300px breit</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-secondary">Logo hochladen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Nummernkreise</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-grid form-grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rechnungs-Präfix</label>
|
||||||
|
<input type="text" name="invoice_nr_prefix" value="<?= $h($settings['invoice_nr_prefix'] ?? 'AR') ?>" maxlength="10">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Beleg-Präfix</label>
|
||||||
|
<input type="text" name="beleg_nr_prefix" value="<?= $h($settings['beleg_nr_prefix'] ?? 'BLG') ?>" maxlength="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Standard-Zahlungsziel (Tage)</label>
|
||||||
|
<input type="number" name="payment_days_default" value="<?= $h($settings['payment_days_default'] ?? 14) ?>" min="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- ── Kontenrahmen ──────────────────────────────────────────── -->
|
||||||
|
<div class="card mt-2" style="margin-top:1.5rem">
|
||||||
|
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<div class="card-title">Kontenrahmen (SKR03)</div>
|
||||||
|
<a href="/accounts/new?from=settings" class="btn btn-primary btn-sm">+ Konto anlegen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div style="padding:.75rem 1.25rem;border-bottom:1px solid var(--color-border);display:flex;gap:.5rem;flex-wrap:wrap">
|
||||||
|
<form method="get" style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
|
||||||
|
<input type="search" name="search" placeholder="Kto.-Nr. oder Bezeichnung…"
|
||||||
|
value="<?= $h($filter['search'] ?? '') ?>" style="width:220px">
|
||||||
|
<select name="kontenart">
|
||||||
|
<option value="">Alle Kontenarten</option>
|
||||||
|
<?php foreach (['Aktiv','Passiv','Aufwand','Ertrag','Neutral'] as $art): ?>
|
||||||
|
<option value="<?= $h($art) ?>"<?= ($filter['kontenart'] ?? '') === $art ? ' selected' : '' ?>><?= $h($art) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm">Filtern</button>
|
||||||
|
<?php if (!empty($filter['search']) || !empty($filter['kontenart'])): ?>
|
||||||
|
<a href="/settings" class="btn btn-secondary btn-sm">✕</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<span class="text-muted text-sm"><?= count($accounts) ?> Konten</span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kto.-Nr.</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th>Kontenart</th>
|
||||||
|
<th>Steuercode</th>
|
||||||
|
<th>UStVA Kz</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($accounts as $acc):
|
||||||
|
$badgeClass = match($acc['kontenart']) {
|
||||||
|
'Ertrag' => 'badge-green',
|
||||||
|
'Aufwand' => 'badge-red',
|
||||||
|
'Aktiv' => 'badge-blue',
|
||||||
|
'Passiv' => 'badge-yellow',
|
||||||
|
default => 'badge-gray',
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<tr style="<?= $acc['aktiv'] ? '' : 'opacity:.45' ?>">
|
||||||
|
<td class="td-mono font-bold"><?= $h($acc['kontonummer']) ?></td>
|
||||||
|
<td><?= $h($acc['bezeichnung']) ?><?= $acc['aktiv'] ? '' : ' <span class="badge badge-gray" style="font-size:.7rem">inaktiv</span>' ?></td>
|
||||||
|
<td><span class="badge <?= $badgeClass ?>"><?= $h($acc['kontenart']) ?></span></td>
|
||||||
|
<td class="td-muted"><?= $h($acc['steuercode'] ?? '–') ?></td>
|
||||||
|
<td class="td-muted"><?= $h($acc['ustva_kz'] ?? '–') ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($settings['test_mode'] ?? 1): ?>
|
||||||
|
<div style="display:flex;gap:.35rem">
|
||||||
|
<a href="/accounts/<?= $h($acc['id']) ?>/edit?from=settings"
|
||||||
|
class="btn btn-secondary btn-sm">Bearbeiten</a>
|
||||||
|
<form method="post" action="/accounts/<?= $h($acc['id']) ?>/delete"
|
||||||
|
data-confirm="Konto '<?= $h($acc['kontonummer']) ?>' wirklich löschen?">
|
||||||
|
<input type="hidden" name="_back" value="/settings">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($accounts)): ?>
|
||||||
|
<tr><td colspan="6" class="text-center text-muted" style="padding:2rem">Keine Konten gefunden.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateModeLabel(cb) {
|
||||||
|
const track = document.getElementById('toggle_track');
|
||||||
|
const thumb = document.getElementById('toggle_thumb');
|
||||||
|
const label = document.getElementById('mode_label');
|
||||||
|
const hint = document.getElementById('mode_hint');
|
||||||
|
if (cb.checked) {
|
||||||
|
track.style.background = '#f59e0b';
|
||||||
|
thumb.style.left = '3px';
|
||||||
|
label.textContent = 'Testinstallation (Löschen von Buchungen erlaubt)';
|
||||||
|
hint.textContent = 'Im Testmodus können Buchungen und Belege direkt gelöscht werden. Nicht GoBD-konform.';
|
||||||
|
hint.style.color = '#92400e';
|
||||||
|
cb.closest('.card').style.borderColor = '#f59e0b';
|
||||||
|
cb.closest('.card').querySelector('.badge').className = 'badge badge-yellow';
|
||||||
|
cb.closest('.card').querySelector('.badge').textContent = 'Testinstallation';
|
||||||
|
} else {
|
||||||
|
track.style.background = '#22c55e';
|
||||||
|
thumb.style.left = '25px';
|
||||||
|
label.textContent = 'Produktivbetrieb (GoBD-strikt)';
|
||||||
|
hint.textContent = 'Im Produktivmodus sind Buchungen unveränderlich. Korrekturen nur per Stornobuchung möglich.';
|
||||||
|
hint.style.color = '#166534';
|
||||||
|
cb.closest('.card').style.borderColor = '#22c55e';
|
||||||
|
cb.closest('.card').querySelector('.badge').className = 'badge badge-green';
|
||||||
|
cb.closest('.card').querySelector('.badge').textContent = 'Produktiv';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
12
src/Views/tax/euer_index.php
Normal file
12
src/Views/tax/euer_index.php
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div class="card" style="max-width:400px">
|
||||||
|
<div class="card-header"><div class="card-title">Geschäftsjahr wählen</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.75rem">
|
||||||
|
<?php foreach ($years as $y): ?>
|
||||||
|
<a href="/tax/euer/<?= $h($y) ?>" class="btn btn-secondary"><?= $h($y) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
82
src/Views/tax/euer_show.php
Normal file
82
src/Views/tax/euer_show.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">EÜR Geschäftsjahr <?= $h($year) ?></div>
|
||||||
|
<div style="display:flex;gap:.5rem">
|
||||||
|
<a href="/tax/euer/<?= $h($year) ?>/pdf" class="btn btn-primary">PDF herunterladen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="3" style="background:#16a34a;color:#fff;text-align:center">Betriebseinnahmen</th>
|
||||||
|
<th style="width:20px;background:#f0f0f0"></th>
|
||||||
|
<th colspan="3" style="background:#dc2626;color:#fff;text-align:center">Betriebsausgaben</th>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th style="width:80px">Kto.</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th class="td-right" style="width:120px">Betrag</th>
|
||||||
|
<th style="background:#f0f0f0"></th>
|
||||||
|
<th style="width:80px">Kto.</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th class="td-right" style="width:120px">Betrag</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$maxRows = max(count($euer['einnahmen']), count($euer['ausgaben']));
|
||||||
|
for ($i = 0; $i < $maxRows; $i++):
|
||||||
|
$e = $euer['einnahmen'][$i] ?? null;
|
||||||
|
$a = $euer['ausgaben'][$i] ?? null;
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<?php if ($e): ?>
|
||||||
|
<td class="td-mono text-sm" style="color:var(--color-muted)"><?= $h($e['kontonummer']) ?></td>
|
||||||
|
<td><?= $h($e['bezeichnung']) ?></td>
|
||||||
|
<td class="td-right td-mono" style="color:var(--color-success)"><?= $fmt($e['netto']) ?></td>
|
||||||
|
<?php else: ?>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
<td style="background:#f8f8f8"></td>
|
||||||
|
<?php if ($a): ?>
|
||||||
|
<td class="td-mono text-sm" style="color:var(--color-muted)"><?= $h($a['kontonummer']) ?></td>
|
||||||
|
<td><?= $h($a['bezeichnung']) ?></td>
|
||||||
|
<td class="td-right td-mono" style="color:var(--color-danger)"><?= $fmt($a['netto']) ?></td>
|
||||||
|
<?php else: ?>
|
||||||
|
<td colspan="3"></td>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tr>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr style="background:#e8ecf0;font-weight:700;font-size:.95rem">
|
||||||
|
<td colspan="2">Summe Einnahmen</td>
|
||||||
|
<td class="td-right td-mono" style="color:var(--color-success)"><?= $fmt($euer['total_einnahmen']) ?></td>
|
||||||
|
<td style="background:#d0d7e0"></td>
|
||||||
|
<td colspan="2">Summe Ausgaben</td>
|
||||||
|
<td class="td-right td-mono" style="color:var(--color-danger)"><?= $fmt($euer['total_ausgaben']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="font-weight:700;font-size:1.05rem;background:<?= $euer['gewinn'] >= 0 ? '#dcfce7' : '#fee2e2' ?>">
|
||||||
|
<td colspan="7" style="text-align:right;padding:1rem">
|
||||||
|
<?= $euer['gewinn'] >= 0 ? 'Überschuss (Gewinn)' : 'Fehlbetrag (Verlust)' ?>:
|
||||||
|
<span style="color:<?= $euer['gewinn'] >= 0 ? 'var(--color-success)' : 'var(--color-danger)' ?>;font-size:1.2rem;margin-left:1rem">
|
||||||
|
<?= $fmt($euer['gewinn']) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($euer['einnahmen']) && empty($euer['ausgaben'])): ?>
|
||||||
|
<div class="flash flash-info mt-2">Für <?= $h($year) ?> wurden noch keine Buchungen erfasst.</div>
|
||||||
|
<?php endif; ?>
|
||||||
81
src/Views/tax/ustva_index.php
Normal file
81
src/Views/tax/ustva_index.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php use Steuerkram\View; $h = fn($v) => View::h($v); ?>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:300px 1fr;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
<!-- Formular: Monat wählen -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><div class="card-title">UStVA erstellen</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="" id="ustva-nav-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Jahr</label>
|
||||||
|
<select name="year" id="ustva-year">
|
||||||
|
<?php foreach ($years as $y): ?>
|
||||||
|
<option value="<?= $y ?>"<?= $y == date('Y') ? ' selected' : '' ?>><?= $y ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Monat</label>
|
||||||
|
<select name="month" id="ustva-month">
|
||||||
|
<?php
|
||||||
|
$mnames = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
|
||||||
|
foreach ($mnames as $i => $m):
|
||||||
|
?>
|
||||||
|
<option value="<?= $i+1 ?>"<?= ($i+1) == (int)date('m') ? ' selected' : '' ?>><?= $m ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary w-full" onclick="
|
||||||
|
var y=document.getElementById('ustva-year').value;
|
||||||
|
var m=document.getElementById('ustva-month').value;
|
||||||
|
window.location='/tax/ustva/'+y+'/'+m;
|
||||||
|
">UStVA anzeigen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste vergangener Einreichungen -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><div class="card-title">Bisherige Voranmeldungen</div></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitraum</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
<th>Eingereicht</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($submissions as $s): ?>
|
||||||
|
<?php $mnames = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; ?>
|
||||||
|
<tr>
|
||||||
|
<td><?= $mnames[$s['month']-1] ?> <?= $h($s['year']) ?></td>
|
||||||
|
<td>
|
||||||
|
<?php if ($s['status'] === 'Eingereicht'): ?>
|
||||||
|
<span class="badge badge-green">Eingereicht</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge badge-yellow">Erstellt</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="td-muted text-sm"><?= $h($s['created_at']) ?></td>
|
||||||
|
<td class="td-muted text-sm"><?= $h($s['submitted_at'] ?? '–') ?></td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:.4rem">
|
||||||
|
<a href="/tax/ustva/<?= $h($s['year']) ?>/<?= $h($s['month']) ?>" class="btn btn-secondary btn-sm">Ansehen</a>
|
||||||
|
<a href="/tax/ustva/<?= $h($s['year']) ?>/<?= $h($s['month']) ?>/xml" class="btn btn-secondary btn-sm">XML</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (empty($submissions)): ?>
|
||||||
|
<tr><td colspan="5" class="text-center text-muted" style="padding:2rem">Noch keine Voranmeldungen erstellt.</td></tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
137
src/Views/tax/ustva_show.php
Normal file
137
src/Views/tax/ustva_show.php
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
use Steuerkram\View;
|
||||||
|
$h = fn($v) => View::h($v);
|
||||||
|
$fmt = fn($v) => View::formatMoney($v);
|
||||||
|
$fmtKz = fn($v) => $v !== null ? number_format((float)$v, 2, ',', '.') . ' €' : '–';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 340px;gap:1.5rem;align-items:start">
|
||||||
|
|
||||||
|
<!-- Linke Seite: Kennzahlen-Tabelle -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title">Kennzahlen – <?= $h($months[$month-1]) ?> <?= $h($year) ?></div>
|
||||||
|
<?php if (!empty($settings['ustva_steuernummer'])): ?>
|
||||||
|
<span class="text-muted text-sm">StNr: <?= $h($settings['ustva_steuernummer']) ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<a href="/settings" class="badge badge-red">⚠ Steuernummer fehlt in Einstellungen</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kz</th>
|
||||||
|
<th>Bezeichnung</th>
|
||||||
|
<th class="td-right">Bemessungsgrundlage</th>
|
||||||
|
<th class="td-right">Steuer</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$sections = [
|
||||||
|
'Steuerpflichtige Umsätze' => ['81', '86', '35', '41', '44'],
|
||||||
|
'Umsatzsteuer auf Umsätze' => ['83', '84'],
|
||||||
|
'Vorsteuerbeträge' => ['66', '61', '62'],
|
||||||
|
'Verbleibende Zahllast' => ['69'],
|
||||||
|
];
|
||||||
|
foreach ($sections as $sectionTitle => $kzNrs):
|
||||||
|
?>
|
||||||
|
<tr style="background:#f8fafc">
|
||||||
|
<td colspan="4" style="font-size:.75rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--color-muted);padding:.4rem 1rem">
|
||||||
|
<?= $h($sectionTitle) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php foreach ($kzNrs as $nr):
|
||||||
|
if (!isset($kz[$nr])) continue;
|
||||||
|
$field = $kz[$nr];
|
||||||
|
$isResult = in_array($nr, ['69']);
|
||||||
|
$isNegative = $isResult && ($field['steuer'] ?? 0) < 0;
|
||||||
|
?>
|
||||||
|
<tr style="<?= $isResult ? 'font-weight:700;background:#f0f4ff' : '' ?>">
|
||||||
|
<td class="td-mono" style="color:var(--color-muted);width:50px"><?= $h($nr) ?></td>
|
||||||
|
<td><?= $h($field['name']) ?></td>
|
||||||
|
<td class="td-right td-mono">
|
||||||
|
<?= $field['betrag'] !== null ? $fmtKz($field['betrag']) : '–' ?>
|
||||||
|
</td>
|
||||||
|
<td class="td-right td-mono" style="<?= $isNegative ? 'color:var(--color-success)' : ($isResult ? 'color:var(--color-danger)' : '') ?>">
|
||||||
|
<?= $field['steuer'] !== null ? $fmtKz($field['steuer']) : '–' ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rechte Seite: Aktionen -->
|
||||||
|
<div>
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Aktionen</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:.75rem">
|
||||||
|
<a href="/tax/ustva/<?= $h($year) ?>/<?= $h($month) ?>/xml" class="btn btn-primary">
|
||||||
|
⬇ ELSTER XML herunterladen
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/tax/ustva/<?= $h($year) ?>/<?= $h($month) ?>/save">
|
||||||
|
<button type="submit" class="btn btn-secondary w-full">Voranmeldung speichern</button>
|
||||||
|
</form>
|
||||||
|
<?php if (!empty($saved)): ?>
|
||||||
|
<div class="flash flash-info" style="margin:0">
|
||||||
|
Gespeichert am <?= $h($saved['created_at']) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-header"><div class="card-title">Zusammenfassung</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table style="width:100%;font-size:.9rem">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 19 %</td>
|
||||||
|
<td class="td-right td-mono"><?= $fmtKz($kz['81']['betrag']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem 0;color:var(--color-muted)">Umsatz 7 %</td>
|
||||||
|
<td class="td-right td-mono"><?= $fmtKz($kz['86']['betrag']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem 0;color:var(--color-muted)">USt gesamt</td>
|
||||||
|
<td class="td-right td-mono"><?= $fmtKz(($kz['83']['steuer'] ?? 0) + ($kz['84']['steuer'] ?? 0)) ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:.35rem 0;color:var(--color-muted)">Vorsteuer</td>
|
||||||
|
<td class="td-right td-mono" style="color:var(--color-success)">– <?= $fmtKz($kz['66']['steuer'] ?? 0) ?></td>
|
||||||
|
</tr>
|
||||||
|
<tr style="border-top:2px solid #000;font-weight:700">
|
||||||
|
<td style="padding:.5rem 0"><?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'Vorauszahlung' : 'Erstattung' ?></td>
|
||||||
|
<td class="td-right td-mono" style="color:<?= ($kz['69']['steuer'] ?? 0) >= 0 ? 'var(--color-danger)' : 'var(--color-success)' ?>">
|
||||||
|
<?= $fmtKz($kz['69']['steuer'] ?? 0) ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><div class="card-title">Navigation</div></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<?php
|
||||||
|
$prevMonth = $month - 1;
|
||||||
|
$prevYear = $year;
|
||||||
|
if ($prevMonth < 1) { $prevMonth = 12; $prevYear--; }
|
||||||
|
$nextMonth = $month + 1;
|
||||||
|
$nextYear = $year;
|
||||||
|
if ($nextMonth > 12) { $nextMonth = 1; $nextYear++; }
|
||||||
|
?>
|
||||||
|
<div style="display:flex;justify-content:space-between">
|
||||||
|
<a href="/tax/ustva/<?= $prevYear ?>/<?= $prevMonth ?>" class="btn btn-secondary btn-sm">‹ <?= $h($months[$prevMonth-1]) ?></a>
|
||||||
|
<a href="/tax/ustva/<?= $nextYear ?>/<?= $nextMonth ?>" class="btn btn-secondary btn-sm"><?= $h($months[$nextMonth-1]) ?> ›</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Loading…
Add table
Reference in a new issue