first version of manualinvoice
This commit is contained in:
22
application/ManualInvoice/ManualInvoiceController.php
Normal file
22
application/ManualInvoice/ManualInvoiceController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoiceController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'Manuelle Rechnungen';
|
||||
protected bool $createText = false;
|
||||
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'invoiceNumber', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'customerName', 'text' => 'Kunde', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'invoiceDate', 'text' => 'Datum', 'table' => ['sortable' => true, 'filter' => 'date']],
|
||||
['key' => 'totalAmount', 'text' => 'Betrag', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||
['value' => 'draft', 'text' => 'Entwurf'],
|
||||
['value' => 'sent', 'text' => 'Gesendet'],
|
||||
['value' => 'paid', 'text' => 'Bezahlt'],
|
||||
]]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
//@formatter:on
|
||||
}
|
||||
186
application/ManualInvoice/ManualInvoiceModel.php
Normal file
186
application/ManualInvoice/ManualInvoiceModel.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
function getMockData() {
|
||||
$mockData = [
|
||||
[
|
||||
'id' => 1, 'invoiceNumber' => 'RE-2025-001', 'customerName' => 'Musterfirma GmbH', 'billingAddressId' => 1,
|
||||
'invoiceDate' => strtotime('2025-09-11'), 'dueDate' => strtotime('2025-09-25'), 'totalAmount' => 948.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IT-Support-Stunden', 'product_info' => 'Remote-Hilfe für Mitarbeiter', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 2, 'price' => 120.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerk-Switch 24-Port', 'product_info' => 'Modell: XYZ-24G', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 1, 'price' => 550.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 2, 'invoiceNumber' => 'RE-2025-002', 'customerName' => 'Beispiel AG', 'billingAddressId' => 2,
|
||||
'invoiceDate' => strtotime('2025-09-14'), 'dueDate' => strtotime('2025-09-28'), 'totalAmount' => 720.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Beratung Digitalisierungsstrategie', 'product_info' => 'Workshop am 05.09.2025', 'start_date' => '2025-09-05', 'end_date' => '2025-09-05', 'amount' => 4, 'price' => 150.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 3, 'invoiceNumber' => 'RE-2025-003', 'customerName' => 'John Doe Services', 'billingAddressId' => 3,
|
||||
'invoiceDate' => strtotime('2025-09-16'), 'dueDate' => strtotime('2025-09-30'), 'totalAmount' => 912.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kabelverlegung LWL', 'product_info' => 'Inhouse-Verkabelung Bürogebäude', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 8, 'price' => 85.00, 'vatrate' => 20],
|
||||
['product_name' => 'LWL-Kabel 8 Fasern', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 100, 'price' => 0.80, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 4, 'invoiceNumber' => 'RE-2025-004', 'customerName' => 'Bau & Co KG', 'billingAddressId' => 4,
|
||||
'invoiceDate' => strtotime('2025-09-06'), 'dueDate' => strtotime('2025-09-20'), 'totalAmount' => 1890.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Netzwerk-Grundinstallation Baustelle', 'product_info' => 'Containerdorf Einrichtung', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 1, 'price' => 1200.00, 'vatrate' => 20],
|
||||
['product_name' => 'Stunden Elektriker', 'product_info' => 'Anpassungen Verteilerkasten', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 5, 'price' => 75.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 5, 'invoiceNumber' => 'RE-2025-005', 'customerName' => 'Creative Solutions', 'billingAddressId' => 5,
|
||||
'invoiceDate' => strtotime('2025-09-15'), 'dueDate' => strtotime('2025-09-29'), 'totalAmount' => 1920.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Web-Entwicklung', 'product_info' => 'Umsetzung Landingpage "Herbst-Aktion"', 'start_date' => '2025-09-01', 'end_date' => '2025-09-12', 'amount' => 10, 'price' => 110.00, 'vatrate' => 20],
|
||||
['product_name' => 'Domain-Registrierung (.at)', 'product_info' => 'herbst-aktion.at', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 500.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 6, 'invoiceNumber' => 'RE-2025-006', 'customerName' => 'Logistik Express', 'billingAddressId' => 6,
|
||||
'invoiceDate' => strtotime('2025-08-28'), 'dueDate' => strtotime('2025-09-11'), 'totalAmount' => 3432.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Software-Lizenz WMS Pro', 'product_info' => 'Jahreslizenz für 10 User', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 2500.00, 'vatrate' => 20],
|
||||
['product_name' => 'Mitarbeiterschulung WMS', 'product_info' => 'Vor Ort am 27.08.2025', 'start_date' => '2025-08-27', 'end_date' => '2025-08-27', 'amount' => 4, 'price' => 90.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 7, 'invoiceNumber' => 'RE-2025-007', 'customerName' => 'Gastro Profi', 'billingAddressId' => 7,
|
||||
'invoiceDate' => strtotime('2025-09-10'), 'dueDate' => strtotime('2025-09-24'), 'totalAmount' => 2577.60, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kassensystem "GastroTouch"', 'product_info' => '2x Terminal, 1x Bondrucker', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 2, 'price' => 899.00, 'vatrate' => 20],
|
||||
['product_name' => 'Installationspauschale', 'product_info' => 'Inkl. Einschulung', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 1, 'price' => 350.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 8, 'invoiceNumber' => 'RE-2025-008', 'customerName' => 'Sicherheitsdienst Huber', 'billingAddressId' => 8,
|
||||
'invoiceDate' => strtotime('2025-09-01'), 'dueDate' => strtotime('2025-09-15'), 'totalAmount' => 1782.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IP Kamera 4K Dome', 'product_info' => 'Modell SEC-4K-D', 'start_date' => '2025-08-29', 'end_date' => '2025-08-29', 'amount' => 8, 'price' => 180.00, 'vatrate' => 20],
|
||||
['product_name' => 'Monatliche Wartungspauschale', 'product_info' => 'September 2025', 'start_date' => '2025-09-01', 'end_date' => '2025-09-30', 'amount' => 1, 'price' => 45.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 9, 'invoiceNumber' => 'RE-2025-009', 'customerName' => 'Praxis Dr. Eder', 'billingAddressId' => 9,
|
||||
'invoiceDate' => strtotime('2025-09-12'), 'dueDate' => strtotime('2025-09-26'), 'totalAmount' => 3090.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Arbeitsstunden IT-Migration', 'product_info' => 'Serverumzug und Client-Setup', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 5, 'price' => 95.00, 'vatrate' => 20],
|
||||
['product_name' => 'Server-Hardware "MedServ"', 'product_info' => 'Spez. für Arztpraxen', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 1800.00, 'vatrate' => 20],
|
||||
['product_name' => 'Datensicherungslösung "CloudSafe"', 'product_info' => 'Einrichtungspauschale', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 300.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 10, 'invoiceNumber' => 'RE-2025-010', 'customerName' => 'Architekturbüro Planweit', 'billingAddressId' => 10,
|
||||
'invoiceDate' => strtotime('2025-09-08'), 'dueDate' => strtotime('2025-09-22'), 'totalAmount' => 357.60, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Plotter Service', 'product_info' => 'Wartung und Reinigung', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 1, 'price' => 250.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerkkabel Cat7', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 40, 'price' => 1.20, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
];
|
||||
|
||||
return $mockData;
|
||||
}
|
||||
|
||||
|
||||
class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $invoiceNumber;
|
||||
public ?int $invoiceDate;
|
||||
public ?int $dueDate;
|
||||
public int $billingAddressId;
|
||||
public ?string $customerName;
|
||||
public ?float $totalAmount;
|
||||
public string $status;
|
||||
public string $positions;
|
||||
public string $closingText;
|
||||
public string $taxText;
|
||||
|
||||
private static function applyFilter(array $data, array $filter): array {
|
||||
if (empty($filter)) {
|
||||
return $data;
|
||||
}
|
||||
return array_filter($data, function ($row) use ($filter) {
|
||||
foreach ($filter as $key => $value) {
|
||||
if (!isset($row[$key]) || empty($value)) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) { // Handle date ranges
|
||||
if (isset($value['from']) && $row[$key] < $value['from']) return false;
|
||||
if (isset($value['to']) && $row[$key] > $value['to']) return false;
|
||||
} else if (is_array($row[$key])) {
|
||||
if (!in_array($value, $row[$key])) return false;
|
||||
} else if (stripos($row[$key], $value) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array
|
||||
|
||||
{
|
||||
$mockData = getMockData();
|
||||
$filteredData = self::applyFilter($mockData, $filter);
|
||||
|
||||
if ($order['key'] !== null) {
|
||||
usort($filteredData, function ($a, $b) use ($order) {
|
||||
if ($a[$order['key']] == $b[$order['key']]) return 0;
|
||||
if ($order['order'] === 'ASC') {
|
||||
return $a[$order['key']] < $b[$order['key']] ? -1 : 1;
|
||||
} else {
|
||||
return $a[$order['key']] > $b[$order['key']] ? -1 : 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
return array_slice($filteredData, $offset, $limit);
|
||||
}
|
||||
return $filteredData;
|
||||
}
|
||||
|
||||
public static function count($filter = []): int {
|
||||
$mockData = getMockData();
|
||||
return count(self::applyFilter($mockData, $filter));
|
||||
}
|
||||
|
||||
public static function get($id) {
|
||||
$mockData = getMockData();
|
||||
foreach ($mockData as $row)
|
||||
if ($row['id'] == $id)
|
||||
return new self($row);
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create($data) {
|
||||
error_log("ManualInvoiceModel::create called with: " . json_encode($data));
|
||||
return time();
|
||||
}
|
||||
|
||||
public static function update($data) {
|
||||
error_log("ManualInvoiceModel::update called with: " . json_encode($data));
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static function delete($id) {
|
||||
error_log("ManualInvoiceModel::delete called with ID: " . $id);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
243
public/js/pages/ManualInvoice/ManualInvoice.css
Normal file
243
public/js/pages/ManualInvoice/ManualInvoice.css
Normal file
@@ -0,0 +1,243 @@
|
||||
/* --- Main Overlay and Layout --- */
|
||||
.manual-invoice-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.invoice-editor-pane, .invoice-preview-pane {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.invoice-editor-pane {
|
||||
flex: 0 0 50%;
|
||||
background-color: #f4f5f7;
|
||||
padding: 1rem;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.invoice-preview-pane {
|
||||
flex: 1 1 auto;
|
||||
background-color: #525659;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: rgba(0, 83, 132, 0.9);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
z-index: 1051;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* --- Responsive Layout & Toggle --- */
|
||||
@media (max-width: 1919px) {
|
||||
.invoice-editor-pane, .invoice-preview-pane {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.manual-invoice-overlay.editor-active-small .invoice-preview-pane {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.manual-invoice-overlay.preview-active-small .invoice-editor-pane {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.manual-invoice-overlay.preview-active-small .invoice-preview-pane {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Editor Pane Specifics --- */
|
||||
.editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
padding-top: 1rem;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.editor-content .card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* --- Invoice Preview Styles (mimicking PDF) --- */
|
||||
.invoice-preview-document {
|
||||
width: 210mm;
|
||||
min-height: 297mm;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
|
||||
font-family: "Open Sans", sans-serif, Verdana;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-header-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
padding: 2cm 2cm 0 2cm;
|
||||
}
|
||||
|
||||
.customer-details {
|
||||
vertical-align: bottom;
|
||||
font-size: 14px;
|
||||
padding-left: 30pt;
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
.invoice-details-cell {
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.invoice-details-box {
|
||||
border: 2px solid #e1e1e1;
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.invoice-details-box table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.invoice-details-box table td:first-child {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 24px 2cm 0 2cm;
|
||||
height: 1px;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.preview-main {
|
||||
padding: 1rem 2cm 0 2cm;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.positions-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.positions-table th {
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid black;
|
||||
padding: 8px 4px;
|
||||
height: 28px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.positions-table td {
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.positions-table tbody tr.uneven {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
|
||||
.positions-table .matchcode {
|
||||
padding-left: 12pt;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.totals-section {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.totals-table {
|
||||
width: 50%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.totals-table td, .totals-table th {
|
||||
padding: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.totals-table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.totals-table .netto {
|
||||
font-weight: bold;
|
||||
background-color: #ebebeb;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.totals-table .ust {
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.totals-table .brutto {
|
||||
font-weight: bold;
|
||||
background-color: #ebebeb;
|
||||
border-bottom: 3px double black;
|
||||
}
|
||||
|
||||
.payment-info {
|
||||
margin-top: 20pt;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
padding: 1rem 2cm 2cm 2cm;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-footer .page-number {
|
||||
text-align: right;
|
||||
}
|
||||
372
public/js/pages/ManualInvoice/ManualInvoice.js
Normal file
372
public/js/pages/ManualInvoice/ManualInvoice.js
Normal file
@@ -0,0 +1,372 @@
|
||||
Vue.component('manual-invoice', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
|
||||
<tt-button text="Test Prefill & Reload" icon="fas fa-magic" @click="testPrefill" additional-class="btn-info"/>
|
||||
</div>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
emit-edit
|
||||
@edit="openModal($event)">
|
||||
<template v-slot:totalamount="{ row }">
|
||||
{{ formatPrice(row.totalAmount) }}
|
||||
</template>
|
||||
<template v-slot:invoicedate="{ row }">
|
||||
{{ formatDate(row.invoiceDate) }}
|
||||
</template>
|
||||
</tt-table-crud>
|
||||
|
||||
<manual-invoice-modal
|
||||
v-if="isModalOpen"
|
||||
:initial-data="editingInvoiceData"
|
||||
@close="closeModal"
|
||||
@save="handleSave"
|
||||
/>
|
||||
</tt-card>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
isModalOpen: false,
|
||||
editingInvoiceData: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const prefillData = localStorage.getItem('ManualInvoice_create');
|
||||
if (prefillData) {
|
||||
try {
|
||||
this.editingInvoiceData = JSON.parse(prefillData);
|
||||
this.isModalOpen = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse prefill data:", e);
|
||||
} finally {
|
||||
localStorage.removeItem('ManualInvoice_create');
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openModal(invoice = null) {
|
||||
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
|
||||
this.isModalOpen = true;
|
||||
},
|
||||
closeModal() {
|
||||
this.isModalOpen = false;
|
||||
this.editingInvoiceData = null;
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
},
|
||||
handleSave(invoiceData) {
|
||||
console.log("--- INVOICE SAVED (DEMO) ---");
|
||||
console.log(JSON.parse(JSON.stringify(invoiceData)));
|
||||
window.notify('success', 'Rechnung in der Konsole geloggt!');
|
||||
this.closeModal();
|
||||
},
|
||||
testPrefill() {
|
||||
const mockInvoice = {
|
||||
id: null,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-XXXX`,
|
||||
invoiceDate: moment().unix(),
|
||||
dueDate: moment().add(14, 'days').unix(),
|
||||
status: 'draft',
|
||||
billingAddressId: 1, // Example ID for autocomplete to fetch
|
||||
customer: {}, // Will be populated by watcher
|
||||
positions: [
|
||||
{ product_name: 'Stunden Techniker', product_info: 'Arbeiten an Server-Infrastruktur', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 3.5, price: 95.00, vatrate: 20 },
|
||||
{ product_name: 'Anfahrtspauschale', product_info: '', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 1, price: 45.00, vatrate: 20 }
|
||||
],
|
||||
closingText: 'Wir bedanken uns für die gute Zusammenarbeit.',
|
||||
taxText: ''
|
||||
};
|
||||
localStorage.setItem('ManualInvoice_create', JSON.stringify(mockInvoice));
|
||||
window.location.reload();
|
||||
},
|
||||
formatPrice(value) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
|
||||
},
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '–';
|
||||
return moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('manual-invoice-modal', {
|
||||
props: ['initialData'],
|
||||
template: `
|
||||
<div class="manual-invoice-overlay" :class="overlayClasses" @keydown.ctrl.q.prevent="togglePreviewVisibility" tabindex="-1" ref="overlay">
|
||||
<div class="info-bar" v-if="!isLargeScreen">
|
||||
<i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.
|
||||
</div>
|
||||
|
||||
<div class="invoice-editor-pane" v-show="isLargeScreen || !showPreviewOnSmallScreen">
|
||||
<div class="editor-header">
|
||||
<h3>{{ isCreateMode ? 'Neue Rechnung' : 'Rechnung bearbeiten' }}</h3>
|
||||
<div class="editor-actions">
|
||||
<tt-button text="Speichern" icon="fas fa-save" @click="$emit('save', invoiceData)" additional-class="btn-success"/>
|
||||
<tt-button text="Schließen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
|
||||
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingAddressId" sm row />
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
|
||||
<div class="form-grid">
|
||||
<tt-input label="Rechnungsnr." v-model="invoiceData.invoiceNumber" sm/>
|
||||
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoiceDate" :date-range="false" sm/>
|
||||
<tt-date-picker label="Fälligkeitsdatum" v-model="invoiceData.dueDate" :date-range="false" sm/>
|
||||
</div>
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
|
||||
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
|
||||
<tt-textarea label="Schlusstext" v-model="invoiceData.closingText" rows="4"/>
|
||||
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.taxText" rows="2"/>
|
||||
</tt-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
|
||||
<div class="invoice-preview-document">
|
||||
<div style="height: 50px; margin-bottom: 32px">
|
||||
<img alt="Xinon Logo" src="/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
|
||||
</div>
|
||||
<table class="preview-header-table">
|
||||
<tr>
|
||||
<td class="customer-details">
|
||||
<div>{{ invoiceData.customer.company }}</div>
|
||||
<div>{{ invoiceData.customer.name }}</div>
|
||||
<div>{{ invoiceData.customer.street }}</div>
|
||||
<div>{{ invoiceData.customer.zip }} {{ invoiceData.customer.city }}</div>
|
||||
<div v-if="invoiceData.customer.country !== 'Österreich'">{{ invoiceData.customer.country }}</div>
|
||||
</td>
|
||||
<td class="invoice-details-cell">
|
||||
<table class="invoice-details-box">
|
||||
<tr><td>Kundennummer:</td><td>{{ selectedCustomerObject.customer_number || '-' }}</td></tr>
|
||||
<tr><td>Rechnungsnummer:</td><td>{{ invoiceData.invoiceNumber }}</td></tr>
|
||||
<tr><td>Belegdatum:</td><td>{{ formatDate(invoiceData.invoiceDate) }}</td></tr>
|
||||
<tr v-if="invoiceData.customer.uid"><td>Ihre UID:</td><td>{{ invoiceData.customer.uid }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="separator"></div>
|
||||
<div class="preview-main">
|
||||
<h2 style="text-align: center; color: #005384; font-size: 1.5rem; margin-bottom: 1.5rem;">Ihre Rechnung vom {{ formatDate(invoiceData.invoiceDate) }}</h2>
|
||||
<table class="positions-table">
|
||||
<thead>
|
||||
<tr class="uneven">
|
||||
<th style="text-align: left; padding-left: 4pt;">Leistung / Produkt</th>
|
||||
<th style="text-align: center;">Zeitraum</th>
|
||||
<th style="text-align: right;">Preis</th>
|
||||
<th style="text-align: center;">Menge</th>
|
||||
<th style="text-align: right;">Netto €</th>
|
||||
<th style="text-align: right;">Ust. %</th>
|
||||
<th style="text-align: right; padding-right: 4pt;">Brutto €</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(p, index) in invoiceData.positions">
|
||||
<tr :class="{'uneven': index % 2 === 0}"> <td style="vertical-align: top; padding-left: 4pt;">
|
||||
<strong>{{ p.product_name }}</strong>
|
||||
<div v-if="p.product_info" class="matchcode">{{ p.product_info }}</div>
|
||||
</td>
|
||||
<td style="text-align: center; vertical-align: top;">{{ formatPeriod(p.start_date, p.end_date) }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ formatPrice(p.price) }}</td>
|
||||
<td style="text-align: center; vertical-align: top;">{{ p.amount }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ formatPrice((p.amount || 0) * (p.price || 0)) }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ p.vatrate }}%</td>
|
||||
<td style="text-align: right; padding-right: 4pt; vertical-align: top;">{{ formatPrice(((p.amount || 0) * (p.price || 0)) * (1 + (p.vatrate || 0) / 100)) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-section">
|
||||
<table class="totals-table">
|
||||
<tr class="netto">
|
||||
<th>Gesamtbetrag Netto:</th>
|
||||
<td>{{ formatPrice(totals.net) }} €</td>
|
||||
</tr>
|
||||
<tr class="ust" v-for="(vatValue, vatRate) in totals.vat" :key="vatRate">
|
||||
<th>+ Umsatzsteuer {{ vatRate }}%:</th>
|
||||
<td>{{ formatPrice(vatValue) }} €</td>
|
||||
</tr>
|
||||
<tr class="brutto">
|
||||
<th>Gesamtbetrag Brutto:</th>
|
||||
<td>{{ formatPrice(totals.gross) }} €</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="payment-info">
|
||||
<p v-if="invoiceData.taxText" style="font-weight: bold;">{{invoiceData.taxText}}</p>
|
||||
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b>{{ formatDate(invoiceData.dueDate) }}</b> auf folgendes Konto:<br />
|
||||
<b style="padding-left: 4pt;">IBAN: {{ bankDetails.iban }}</b><br />
|
||||
<b style="padding-left: 4pt;">BIC: {{ bankDetails.bic }}</b><br /><br />
|
||||
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an.
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-footer">
|
||||
<div style="color:grey;text-align: center; width: 100%;">
|
||||
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
|
||||
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
|
||||
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
|
||||
<span>IBAN: {{ bankDetails.iban }} | BIC: {{ bankDetails.bic }}</span><br>
|
||||
</div>
|
||||
<div class="page-number">Seite 1 von 1</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data() {
|
||||
return {
|
||||
isCreateMode: !this.initialData || !this.initialData.id,
|
||||
customerApiUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
|
||||
selectedCustomerObject: {},
|
||||
isLargeScreen: window.innerWidth >= 1920,
|
||||
showPreviewOnSmallScreen: false,
|
||||
invoiceData: {
|
||||
id: null,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-`,
|
||||
invoiceDate: moment().unix(),
|
||||
dueDate: moment().add(14, 'days').unix(),
|
||||
status: 'draft',
|
||||
billingAddressId: null,
|
||||
customer: { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' },
|
||||
positions: [],
|
||||
closingText: 'Wir danken für Ihren Auftrag und verbleiben mit freundlichen Grüßen,\nIhr Xinon Team',
|
||||
taxText: '',
|
||||
},
|
||||
bankDetails: {
|
||||
iban: 'ATXX XXXX XXXX XXXX XXXX',
|
||||
bic: 'XXXXXXXX'
|
||||
},
|
||||
positionsConfig: {
|
||||
fields: {
|
||||
product_name: { type: 'input', label: 'Bezeichnung' },
|
||||
product_info: { type: 'input', label: 'Zusatzinfo' },
|
||||
start_date: { type: 'input', label: 'Start', inputType: 'date' },
|
||||
end_date: { type: 'input', label: 'Ende', inputType: 'date' },
|
||||
amount: { type: 'input', label: 'Menge', inputType: 'number' },
|
||||
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
|
||||
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
|
||||
},
|
||||
validateForm: (formData) => {
|
||||
if (!formData.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
|
||||
if (!formData.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
|
||||
if (formData.price === null || formData.price === undefined) { window.notify('error', 'Preis ist erforderlich.'); return false; }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
overlayClasses() {
|
||||
return {
|
||||
'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen,
|
||||
'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen,
|
||||
};
|
||||
},
|
||||
totals() {
|
||||
let net = 0;
|
||||
const vat = {};
|
||||
if (!Array.isArray(this.invoiceData.positions)) return { net: 0, vat: {}, gross: 0 };
|
||||
|
||||
this.invoiceData.positions.forEach(p => {
|
||||
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
|
||||
const vatRate = parseInt(p.vatrate) || 0;
|
||||
net += lineTotal;
|
||||
if (!vat[vatRate]) { vat[vatRate] = 0; }
|
||||
vat[vatRate] += lineTotal * (vatRate / 100);
|
||||
});
|
||||
const gross = net + Object.values(vat).reduce((sum, v) => sum + v, 0);
|
||||
return { net, vat, gross };
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'invoiceData.billingAddressId': {
|
||||
async handler(newId) {
|
||||
if (!newId) {
|
||||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||||
this.selectedCustomerObject = {};
|
||||
return;
|
||||
}
|
||||
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
|
||||
if (response.data.status === 'OK' && response.data.result.address) {
|
||||
const addr = response.data.result.address;
|
||||
this.selectedCustomerObject = addr;
|
||||
this.invoiceData.customer = {
|
||||
company: addr.company,
|
||||
name: `${addr.firstname} ${addr.lastname}`,
|
||||
street: addr.street,
|
||||
zip: addr.zip,
|
||||
city: addr.city,
|
||||
country: 'Österreich',
|
||||
uid: addr.uid
|
||||
};
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
created() {
|
||||
if (this.initialData) {
|
||||
// FIX: Merge initial data with default structure to ensure all keys, especially nested ones, exist.
|
||||
this.invoiceData = {
|
||||
...this.invoiceData, // Start with default structure
|
||||
...JSON.parse(JSON.stringify(this.initialData)) // Overwrite with passed data
|
||||
};
|
||||
// Explicitly ensure nested objects exist if they weren't in initialData
|
||||
if (!this.invoiceData.customer) {
|
||||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||||
}
|
||||
// Ensure positions is an array
|
||||
if (!Array.isArray(this.invoiceData.positions)) {
|
||||
try {
|
||||
const parsed = JSON.parse(this.invoiceData.positions);
|
||||
this.invoiceData.positions = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
this.invoiceData.positions = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
this.handleResize();
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.overlay) {
|
||||
this.$refs.overlay.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
methods: {
|
||||
close() { this.$emit('close'); },
|
||||
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
|
||||
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
|
||||
formatPrice(value) { return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); },
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
return moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
},
|
||||
formatPeriod(start, end) {
|
||||
if (!start) return '';
|
||||
const startDate = moment(start);
|
||||
const endDate = end ? moment(end) : moment(start);
|
||||
if (!startDate.isValid()) return '';
|
||||
if (startDate.isSame(endDate, 'day')) return startDate.format('DD.MM.YYYY');
|
||||
if(startDate.isValid() && endDate.isValid()) {
|
||||
return `${startDate.format('DD.MM.YYYY')} - ${endDate.format('DD.MM.YYYY')}`;
|
||||
}
|
||||
return startDate.format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user