Merge branch 'Preorder/add-new-filter' into 'master'

Preorder/add new filter

See merge request fronk/thetool!1920
This commit is contained in:
Luca Haid
2025-12-02 14:46:23 +00:00
20 changed files with 1486 additions and 66 deletions

View File

@@ -856,6 +856,16 @@ $pagination_entity_name = "Vorbestellungen";
<?php endif; ?>
<div class="col-sm-12 col-md-1">
<label class="form-label" for="filter_tool_building_type">Gebäude Typ</label>
<select name="filter[tool_building_type]" id="filter_tool_building_type" class="form-control">
<option value="">Alle</option>
<option value="0" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] === "0") ? "selected='selected'" : ""?>>Unbekannt</option>
<option value="1" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] == "1") ? "selected='selected'" : ""?>>EFH</option>
<option value="2" <?=(isset($filter) && array_key_exists("tool_building_type", $filter) && $filter['tool_building_type'] == "2") ? "selected='selected'" : ""?>>MPH</option>
</select>
</div>
</div>
<div class="row mt-2">

View File

@@ -61,7 +61,8 @@ if ($includeTax) {
}
$formattedOfferDate = date("d.m.Y", $offerDate);
$formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
$validityDays = isset($validity) ? (int)$validity : 14;
$formattedValidUntil = date("d.m.Y", strtotime("+$validityDays days", $offerDate));
?>
<!DOCTYPE html>
@@ -116,7 +117,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<table>
<tr>
<td class="label" style="text-align: left"><?= $text['offerNumberLabel'] ?></td>
<td><?= htmlspecialchars($offerNumber) ?></td>
<td><?= htmlspecialchars($offerNumber) ?> - v<?= isset($offer->version) ? $offer->version : 1 ?></td>
<td class="label"><?= $text['offerDateLabel'] ?></td>
<td><?= $formattedOfferDate ?></td>
</tr>
@@ -173,7 +174,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<td class="amount"><?= number_format($p['amount'], 2, ',', '.') ?></td>
<td class="unit"><?= htmlspecialchars($p['articleUnit']) ?></td>
<td class="price"><?= formatPrice($p['price'], '€') ?></td>
<td class="price"><?= htmlspecialchars($p['discount'] . '%') ?></td>
<td class="discount"><?= htmlspecialchars($p['discount'] . '%') ?></td>
<td class="total"><?= formatPrice($p['totalPrice'], '€') ?></td>
</tr>
<?php endforeach; ?>

View File

@@ -179,9 +179,9 @@
<!-- --><?php //if($me->can("WarehouseAdmin")): ?><!--<li><a href="--><?php //=self::getUrl("WarehouseItem")?><!--"><i class="far fa-fw fa-boxes text-info"></i> Lagerbestand (WIP)</a></li>--><?php //endif; ?>
<!-- --><?php //if($me->can("WarehouseAdmin")): ?><!--<li><a href="--><?php //=self::getUrl("WarehouseOrderRecommendation")?><!--"><i class="far fa-fw fa-box-full text-info"></i> Bestellvorschläge (WIP)</a></li>--><?php //endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOrder")?>"><i class="far fa-fw fa-shopping-bag text-info"></i> Bestellungen</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOffer")?>"><i class="far fa-fw fa-file-signature text-info"></i> Angebote</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseOrderRequest")?>"><i class="far fa-fw fa-shopping-cart text-info"></i> Bestellwünsche</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseShippingNote")?>"><i class="far fa-fw fa-shipping-fast text-info"></i> Lieferscheine</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin") || $me->can("WarehouseUser")): ?><li><a href="<?=self::getUrl("WarehouseProject")?>"><i class="fas fa-fw fa-project-diagram text-info"></i> Projekte</a></li><?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseDistributor")?>"><i class="far fa-fw fa-cogs text-info"></i> Administration</a></li><?php endif; ?>
@@ -209,7 +209,7 @@
</li>
<?php endif; ?>
<?php if($me->is(["Admin","netowner","salespartner"]) || $me->can(["Order", "Preorder"])): ?>
<?php if($me->is(["Admin","netowner","salespartner"]) || $me->can(["Order", "Preorder", "WarehouseAdmin"])): ?>
<li class="has-submenu">
<a href="#">
<i class="fal fa-fw fa-money-bill-wave"></i>Verkauf <div class="arrow-down"></div>
@@ -220,6 +220,8 @@
<li><a href="<?=self::getUrl("Preorder", "RimoTypeMap")?>"><i class="far fa-fw fa-map text-info"></i> RIMO Typen-Karte</a></li>
<?php endif; ?>
<?php if($me->can("WarehouseAdmin")): ?><li><a href="<?=self::getUrl("WarehouseOffer")?>"><i class="far fa-fw fa-file-signature text-info"></i> Angebote</a></li><?php endif; ?>
<?php if($me->is(["Admin","salespartner"]) && $me->can("Order")): ?>
<li class="border-top"><a href="<?=self::getUrl("Order")?>"><i class="far fa-fw fa-file-signature text-info"></i> Bestellungen</a></li>
<?php endif; ?>

View File

@@ -871,9 +871,9 @@ class PreorderModel
if (is_array($tool_building_type) && count($tool_building_type)) {
$where .= " AND adb_hausnummer.tool_building_type IN ('" . implode("','", $tool_building_type) . "')";
} else {
$tool_building_type = FronkDB::singleton()->escape($filter['connection_type']);
if ($tool_building_type) {
$where .= " AND adb_hausnummer.tool_building_type like '%$tool_building_type%'";
$tool_building_type = FronkDB::singleton()->escape($filter['tool_building_type']);
if ($tool_building_type === '0' || $tool_building_type) {
$where .= " AND adb_hausnummer.tool_building_type = $tool_building_type ";
}
}
}

View File

@@ -1,5 +1,8 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class WarehouseOfferController extends TTCrud
{
protected string $headerTitle = 'Angebote';
@@ -53,6 +56,7 @@ class WarehouseOfferController extends TTCrud
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
$this->postData['validity'] = 14;
$this->postData['alternativePositions'] = json_encode([]);
return true;
}
@@ -182,11 +186,16 @@ class WarehouseOfferController extends TTCrud
// E-Mail Actions
public function sendOfferEmailAction()
{
//display errors for debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
$_POST = json_decode(file_get_contents('php://input'), true);
$id = $_POST['id'] ?? null;
$recipientEmail = $_POST['email'] ?? null;
$subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH';
$body = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
$bodyText = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
if (!$id || !$recipientEmail) {
self::sendError("ID oder E-Mail-Adresse fehlt.");
@@ -208,16 +217,66 @@ class WarehouseOfferController extends TTCrud
// Generate PDF
$pdfContent = $this->createPDFAction(true, $id, true);
// Send Email
$mail = new Mail();
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
$mail->setSubject($subject);
$mail->setBody($body);
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
// --- HTML Email Generation ---
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Angebot</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
// Logos
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div></body></html>';
$mail = new PHPMailer(true);
try {
// Server settings
$mail->isSMTP();
$mail->Host = TT_PIPEWORK_SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = TT_PIPEWORK_SMTP_USER;
$mail->Password = TT_PIPEWORK_SMTP_PASS;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->Encoding = 'base64';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logos
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
// set replyto to backoffice@xinon.at
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
$mail->Subject = ($subject);
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
$mail->send();
if ($mail->send()) {
// Update offer status and last sent date
WarehouseOfferModel::update($id, ['status' => 'sent', 'lastSentDate' => time()]);
$WarehouseOffer = (array) WarehouseOfferModel::get($id);
$WarehouseOffer['status'] = 'sent';
$WarehouseOffer['lastSentDate'] = time();
WarehouseOfferModel::update($WarehouseOffer);
// Add Journal Entry
WarehouseOfferJournalModel::create([
@@ -228,7 +287,7 @@ class WarehouseOfferController extends TTCrud
]);
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet.']);
} else {
} catch (Exception $e) {
self::sendError('E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo);
}
}
@@ -377,6 +436,7 @@ class WarehouseOfferController extends TTCrud
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
"validity" => $offerData->validity ?? 14,
"closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,

View File

@@ -24,6 +24,7 @@ class WarehouseOfferModel extends TTCrudBaseModel {
public string $closingText;
public string $notes;
public string $status;
public ?int $validity; // New field
public ?int $lastSentDate; // New field
public float $totalAmount;
public int $create;

View File

@@ -2,46 +2,378 @@
class WarehouseProjectController extends TTCrud {
protected string $headerTitle = 'Projekte';
protected string $createText = 'Neues Projekt erstellen';
protected string $singleText = 'Projekt';
protected bool $createText = false;
//@formatter:off
protected array $columns = [
['key' => 'title', 'text' => 'Titel', 'required' => true],
['key' => 'description', 'text' => 'Projektbeschreibung', 'modal' => ['type' => 'textarea']],
['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'datepicker']],
['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'datepicker']],
['key' => 'positions', 'text' => 'Positionen', 'required' => true, 'modal' => ['type' => 'positions-manager', 'config' => [
'header' => 'Positionen',
'fields' => [
'articleId' => ['apiUrl' => '/WarehouseArticle/autoComplete','type' => 'autocomplete','customFieldReference' => 'WarehouseArticle','label' => 'Artikel'],
'amount' => ['type' => 'input', 'label' => 'Menge', 'inputType' => 'number'],
'purpose' => ['type' => 'input', 'label' => 'Zweck'],
],
'validateFormOptions' => [
['key' => 'articleId', 'message' => 'Bitte füllen Sie den Artikel aus'],
['key' => 'amount', 'message' => 'Bitte füllen Sie die Menge aus'],
['key' => 'purpose', 'message' => 'Bitte füllen Sie den Zweck aus'],
],
]], 'table' => false],
['key' => 'linkedOrderIds', 'text' => 'Verlinkte Bestellung', 'modal' => false],
//
['key' => 'assignedPersons', 'text' => 'Zugewiesene Personen', 'modal' => ['type' => 'positions-manager', 'config' => [
'header' => 'Zugewiesene Personen',
'fields' => [
'userId' => ['apiUrl' => '/WarehouseShippingNote/userAutoComplete','type' => 'autocomplete','label' => 'Person','customFieldReference' => 'User']
],
'validateFormOptions' => [
['key' => 'userId', 'message' => 'Bitte füllen Sie die Person aus'],
],
]], 'table' => false],
['key' => 'storageLocation', 'text' => 'Lagerort', 'modal' => ['type' => 'input']],
['key' => 'note', 'text' => 'Notiz', 'modal' => ['type' => 'textarea']],
['key' => 'createBy', 'text' => 'Erstellt von', 'required' => true, 'modal' => ['visible' => false, 'type' => 'select'], 'table' => ['filter' => 'select']],
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false],
['key' => 'id', 'text' => 'ID', 'table' => false, 'modal' => false],
['key' => 'projectNumber', 'text' => 'Projekt-Nr.', 'required' => false, 'modal' => ['disabled' => true, 'placeholder' => 'Wird automatisch generiert']],
['key' => 'title', 'text' => 'Bezeichnung', 'required' => true],
['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [
['value' => 'new', 'text' => 'Neu'],
['value' => 'wip', 'text' => 'In Bearbeitung'],
['value' => 'finished', 'text' => 'Abgeschlossen'],
['value' => 'cancelled', 'text' => 'Storniert'],
]], 'table' => ['filter' => 'select']],
['key' => 'startDate', 'text' => 'Startdatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
['key' => 'endDate', 'text' => 'Enddatum', 'required' => true, 'modal' => ['type' => 'date'], 'table' => ['filter' => 'date']],
['key' => 'financials', 'text' => 'Gesamtsumme', 'required' => false, 'modal' => ['disabled' => true], 'table' => ['formatter' => 'formatPrice']],
['key' => 'storageLocation', 'text' => 'Lagerort', 'required' => false],
['key' => 'externalTeam', 'text' => 'Externes Team', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false],
['key' => 'description', 'text' => 'Beschreibung', 'required' => false, 'modal' => ['type' => 'textarea'], 'table' => false],
['key' => 'create', 'text' => 'Erstellt', 'required' => false, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
//@formatter:on
}
protected array $permissionCheck = ['WarehouseUser'];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => false];
protected function prepareCrudConfig(): void {
if ($this->user->can('WarehouseAdmin')) {
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = true;
}
}
private array $tempInternalTeam = [];
protected function beforeCreate($postData): bool {
$json = json_decode(file_get_contents('php://input'), true);
if ($json) {
$this->postData = array_merge($this->postData ?? [], $json);
}
if (isset($this->postData['internalTeam'])) {
$this->tempInternalTeam = $this->postData['internalTeam'];
unset($this->postData['internalTeam']);
}
$this->postData['projectNumber'] = WarehouseProjectModel::getNextProjectNumber();
// Ensure defaults if not provided
if (!isset($this->postData['status'])) $this->postData['status'] = 'new';
if (!isset($this->postData['financials'])) $this->postData['financials'] = 0.00;
return true;
}
protected function afterCreate($id, $postData): void
{
WarehouseProjectJournalModel::create([
'projectId' => $id,
'text' => 'Projekt erstellt.',
'createBy' => $this->user->id,
'create' => time()
]);
// Handle initial Internal Team
if (!empty($this->tempInternalTeam) && is_array($this->tempInternalTeam)) {
foreach ($this->tempInternalTeam as $userId) {
WarehouseProjectMemberModel::create([
'projectId' => $id,
'userId' => $userId,
'create' => time()
]);
$u = UserModel::getOne($userId);
$this->logJournal($id, "Teammitglied initial hinzugefügt: " . ($u ? $u->name : $userId));
}
}
}
protected function afterUpdate($id, $postData): void
{
// Simple journaling of main record update
WarehouseProjectJournalModel::create([
'projectId' => $id,
'text' => 'Projektstammdaten aktualisiert.',
'createBy' => $this->user->id,
'create' => time()
]);
}
// --- API for Vue ---
public function getProjectDetailsAction() {
$id = $this->request->id;
if (!$id) self::sendError("Projekt ID fehlt");
$project = WarehouseProjectModel::get($id);
if (!$project) self::sendError("Projekt nicht gefunden");
self::returnJson(['project' => $project]);
}
public function getTasksAction() {
$projectId = $this->request->id;
if (!$projectId) self::sendError("Projekt ID fehlt");
$tasks = WarehouseProjectTaskModel::getAll(['projectId' => $projectId], null, 0, ['key' => 'order', 'order' => 'ASC']);
foreach ($tasks as $task) {
if ($task->assignedUserId) {
$user = UserModel::getOne($task->assignedUserId);
$task->assignedUserName = $user ? $user->name : 'Unbekannt';
} else {
$task->assignedUserName = null;
}
}
self::returnJson($tasks);
}
public function saveTaskAction() {
$data = json_decode(file_get_contents('php://input'), true);
$projectId = $data['projectId'] ?? null;
if (!$projectId) self::sendError("Projekt ID fehlt");
$taskData = [
'projectId' => $projectId,
'title' => $data['title'],
'description' => $data['description'] ?? '',
'status' => $data['status'] ?? 'todo',
'assignedUserId' => !empty($data['assignedUserId']) ? $data['assignedUserId'] : null,
'createBy' => $this->user->id,
'create' => time()
];
if (!empty($data['id'])) {
$existingTask = WarehouseProjectTaskModel::get($data['id']);
if (!$existingTask) self::sendError("Aufgabe nicht gefunden");
// Merge existing data with new data to ensure all required fields are present
$updatedData = array_merge((array)$existingTask, $taskData);
$updatedData['id'] = $data['id']; // Ensure ID is in the data for update
// update method expects an array with 'id' key for update.
WarehouseProjectTaskModel::update($updatedData);
$this->logJournal($projectId, "Aufgabe aktualisiert: {$data['title']}");
} else {
// Get max order to append
$count = WarehouseProjectTaskModel::count(['projectId' => $projectId]);
$taskData['order'] = $count + 1;
WarehouseProjectTaskModel::create($taskData);
$this->logJournal($projectId, "Aufgabe erstellt: {$data['title']}");
}
self::returnJson(['success' => true]);
}
public function updateTaskStatusAction() {
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['id']) || empty($data['status'])) self::sendError("Daten fehlen");
$task = WarehouseProjectTaskModel::get($data['id']);
if ($task) {
// Retrieve existing task data to preserve projectId and other required fields
$updatedData = (array)$task;
$updatedData['status'] = $data['status'];
// WarehouseProjectTaskModel::update expects an array with 'id' key for update.
WarehouseProjectTaskModel::update($updatedData);
$this->logJournal($task->projectId, "Aufgabenstatus '{$task->title}' geändert auf {$data['status']}");
}
self::returnJson(['success' => true]);
}
public function deleteTaskAction() {
$id = $this->request->id;
if (!$id) self::sendError("ID fehlt");
$task = WarehouseProjectTaskModel::get($id);
if ($task) {
WarehouseProjectTaskModel::delete($id);
$this->logJournal($task->projectId, "Aufgabe gelöscht: {$task->title}");
}
self::returnJson(['success' => true]);
}
public function getTeamAction() {
$projectId = $this->request->id;
if (!$projectId) self::sendError("ID fehlt");
$members = WarehouseProjectMemberModel::getAll(['projectId' => $projectId]);
$users = [];
foreach($members as $m) {
$u = UserModel::getOne($m->userId);
if ($u) {
$users[] = [
'memberId' => $m->id,
'userId' => $u->id,
'name' => $u->name,
'role' => $m->role
];
}
}
self::returnJson($users);
}
public function addTeamMemberAction() {
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['projectId']) || empty($data['userId'])) self::sendError("Daten fehlen");
$exists = WarehouseProjectMemberModel::count(['projectId' => $data['projectId'], 'userId' => $data['userId']]);
if ($exists > 0) self::sendError("Benutzer bereits im Team");
WarehouseProjectMemberModel::create([
'projectId' => $data['projectId'],
'userId' => $data['userId'],
'role' => $data['role'] ?? null,
'create' => time()
]);
$u = UserModel::getOne($data['userId']);
$this->logJournal($data['projectId'], "Teammitglied hinzugefügt: " . ($u ? $u->name : $data['userId']));
self::returnJson(['success' => true]);
}
public function removeTeamMemberAction() {
$id = $this->request->id;
$member = WarehouseProjectMemberModel::get($id);
if ($member) {
$u = UserModel::getOne($member->userId);
WarehouseProjectMemberModel::delete($id);
$this->logJournal($member->projectId, "Teammitglied entfernt: " . ($u ? $u->name : $member->userId));
}
self::returnJson(['success' => true]);
}
public function getAvailableOrderRequestsAction() {
// Return open requests (not done, not cancelled)
// You might want to filter out ones already linked to THIS project, but maybe not strictly necessary.
$requests = WarehouseOrderRequest::getAll([], 100, 0, ['key' => 'create', 'order' => 'DESC']);
$available = [];
foreach($requests as $r) {
if (!$r->done && !$r->cancelled) {
$available[] = [
'id' => $r->id,
'purpose' => $r->purpose,
'create' => $r->create
];
}
}
self::returnJson($available);
}
public function getLinkedOrdersAction() {
$projectId = $this->request->id;
if (!$projectId) self::sendError("ID fehlt");
$links = WarehouseProjectOrderRequestModel::getAll(['projectId' => $projectId]);
$result = [];
foreach($links as $l) {
$req = WarehouseOrderRequest::get($l->orderRequestId);
if ($req) {
$positions = json_decode($req->positions, true);
// Resolve actual Orders
$orders = [];
$ids = [];
if (!empty($req->linkedOrderIds)) {
// Check if it is JSON array
$decoded = json_decode($req->linkedOrderIds, true);
if (is_array($decoded)) {
$ids = $decoded;
} else {
// Fallback to comma separated
$ids = explode(',', $req->linkedOrderIds);
}
}
foreach($ids as $oid) {
$oid = trim($oid);
if (empty($oid)) continue;
$o = WarehouseOrderModel::get($oid);
if ($o) {
$orders[] = [
'id' => $o->id,
'orderNumber' => $o->orderNumber,
'status' => $o->status,
'distributorId' => $o->distributorId
];
}
}
$result[] = [
'linkId' => $l->id,
'requestId' => $req->id,
'purpose' => $req->purpose,
'create' => $req->create,
'status' => $req->done ? 'done' : ($req->cancelled ? 'cancelled' : 'open'),
'positionsCount' => is_array($positions) ? count($positions) : 0,
'orders' => $orders
];
}
}
self::returnJson($result);
}
public function linkOrderAction() {
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['projectId']) || empty($data['orderId'])) self::sendError("Daten fehlen");
$order = WarehouseOrderRequest::get($data['orderId']);
if (!$order) self::sendError("Bestellwunsch nicht gefunden");
WarehouseProjectOrderRequestModel::create([
'projectId' => $data['projectId'],
'orderRequestId' => $data['orderId'],
'create' => time()
]);
$this->logJournal($data['projectId'], "Bestellwunsch #{$data['orderId']} verknüpft.");
self::returnJson(['success' => true]);
}
public function unlinkOrderAction() {
$id = $this->request->id;
$link = WarehouseProjectOrderRequestModel::get($id);
if ($link) {
WarehouseProjectOrderRequestModel::delete($id);
$this->logJournal($link->projectId, "Bestellwunsch #{$link->orderRequestId} Verknüpfung aufgehoben.");
}
self::returnJson(['success' => true]);
}
public function createJournalEntryAction() {
$data = json_decode(file_get_contents('php://input'), true);
if (empty($data['projectId'])) self::sendError("Projekt ID fehlt");
$this->logJournal($data['projectId'], $data['message'] ?? '');
self::returnJson(['success' => true]);
}
public function getJournalAction() {
$projectId = $this->request->id;
$logs = WarehouseProjectJournalModel::getAll(['projectId' => $projectId], null, 0, ['order' => 'DESC', 'key' => 'create']);
foreach($logs as $log) {
$u = UserModel::getOne($log->createBy);
$log->userName = $u ? $u->name : 'System';
}
self::returnJson($logs);
}
private function logJournal($projectId, $text) {
WarehouseProjectJournalModel::create([
'projectId' => $projectId,
'text' => $text,
'createBy' => $this->user->id,
'create' => time()
]);
}
// Users for Team Selection
public function getUsersAction() {
$users = array_map(function($u) {
return ['id' => $u->id, 'name' => $u->name];
}, UserModel::search(['employee' => true]));
self::returnJson($users);
}
}

View File

@@ -2,14 +2,35 @@
class WarehouseProjectModel extends TTCrudBaseModel {
public int $id;
public string $projectNumber;
public string $title;
public string $description;
public string $startDate;
public string $endDate;
public ?string $description;
public ?int $startDate;
public ?int $endDate;
public string $status;
public string $priority;
public int $assignedTo;
public float $financials;
public ?string $storageLocation;
public ?string $externalTeam;
public ?int $createdFromOrderId;
public int $createBy;
public int $create;
public static function getNextProjectNumber(): string {
$year = date('Y');
$prefix = "XP-$year-";
$db = self::getDB();
$tableName = self::getFullyQualifiedTable();
$sql = "SELECT projectNumber FROM $tableName WHERE projectNumber LIKE '$prefix%' ORDER BY projectNumber DESC LIMIT 1";
$result = $db->query($sql);
$nextNum = 1;
if ($row = $result->fetch_assoc()) {
$lastNumStr = substr($row['projectNumber'], strrpos($row['projectNumber'], '-') + 1);
$nextNum = intval($lastNumStr) + 1;
}
return $prefix . str_pad((string)$nextNum, 4, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,10 @@
<?php
class WarehouseProjectJournalModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public ?string $text;
public ?string $data; // json
public int $createBy;
public int $create;
}

View File

@@ -0,0 +1,9 @@
<?php
class WarehouseProjectMemberModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public int $userId;
public ?string $role;
public int $create;
}

View File

@@ -0,0 +1,8 @@
<?php
class WarehouseProjectOrderRequestModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public int $orderRequestId;
public int $create;
}

View File

@@ -0,0 +1,14 @@
<?php
class WarehouseProjectTaskModel extends TTCrudBaseModel {
public int $id;
public int $projectId;
public ?int $parentTaskId;
public string $title;
public ?string $description;
public string $status; // todo, in_progress, done
public ?int $assignedUserId;
public int $order;
public int $createBy;
public int $create;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WarehouseOfferAddValidity extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseOffer');
if (!$table->hasColumn('validity')) {
$table->addColumn('validity', 'integer', [
'null' => true,
'default' => null,
'after' => 'status',
]);
$table->update();
}
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseOffer');
if ($table->hasColumn('validity')) {
$table->removeColumn('validity');
$table->update();
}
}
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWarehouseProject extends AbstractMigration
{
public function change(): void
{
if ($this->getEnvironment() == "thetool") {
// Main Project Table
$projects = $this->table('WarehouseProject');
$projects->addColumn('projectNumber', 'string', ['limit' => 20, 'comment' => 'XP-YYYY-NNNN'])
->addColumn('title', 'string', ['limit' => 255])
->addColumn('description', 'text', ['null' => true])
->addColumn('startDate', 'integer', ['null' => true])
->addColumn('endDate', 'integer', ['null' => true])
->addColumn('status', 'enum', ['values' => ['new', 'wip', 'finished', 'cancelled'], 'default' => 'new'])
->addColumn('financials', 'decimal', ['precision' => 10, 'scale' => 2, 'default' => 0.00])
->addColumn('storageLocation', 'string', ['limit' => 255, 'null' => true])
->addColumn('externalTeam', 'text', ['null' => true, 'comment' => 'Free text for external participants'])
->addColumn('createdFromOrderId', 'integer', ['null' => true])
->addColumn('createBy', 'integer')
->addColumn('create', 'integer')
->addIndex(['projectNumber'], ['unique' => true])
->create();
// Tasks Table
$tasks = $this->table('WarehouseProjectTask');
$tasks->addColumn('projectId', 'integer')
->addColumn('parentTaskId', 'integer', ['null' => true])
->addColumn('title', 'string', ['limit' => 255])
->addColumn('description', 'text', ['null' => true])
->addColumn('status', 'enum', ['values' => ['todo', 'in_progress', 'done'], 'default' => 'todo'])
->addColumn('assignedUserId', 'integer', ['null' => true])
->addColumn('order', 'integer', ['default' => 0])
->addColumn('createBy', 'integer')
->addColumn('create', 'integer')
->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
// Project Members (Internal Team)
$members = $this->table('WarehouseProjectMember');
$members->addColumn('projectId', 'integer')
->addColumn('userId', 'integer')
->addColumn('role', 'string', ['limit' => 50, 'null' => true])
->addColumn('create', 'integer')
->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
// Link table for Order Requests
$orderRequests = $this->table('WarehouseProjectOrderRequest');
$orderRequests->addColumn('projectId', 'integer')
->addColumn('orderRequestId', 'integer')
->addColumn('create', 'integer')
->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
// Assuming WarehouseOrderRequest table exists based on controller analysis
->addForeignKey('orderRequestId', 'WarehouseOrderRequest', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
}
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateWarehouseProjectJournal extends AbstractMigration
{
public function change(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('WarehouseProjectJournal');
$table->addColumn('projectId', 'integer')
->addColumn('text', 'text', ['null' => true])
->addColumn('data', 'json', ['null' => true, 'comment' => 'Stores changes/diffs'])
->addColumn('createBy', 'integer')
->addColumn('create', 'integer')
->addForeignKey('projectId', 'WarehouseProject', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->create();
}
}
}

View File

@@ -54,7 +54,7 @@ Vue.component('warehouse-offer-create-basic-offer-modal', {
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
},

View File

@@ -147,8 +147,7 @@ Vue.component('warehouse-offer-modal', {
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.', notes: '',
},
templateName: '',
ignoreFirstAddressChange: false,

View File

@@ -1,11 +1,701 @@
Vue.component('warehouse-project', {
template: `
<tt-card>
<tt-table-crud ref="table">
</tt-table-crud>
<div class="mb-3">
<button @click="openProjectModal('create')" class="btn btn-primary shadow-sm"><i class="fas fa-plus"></i> Projekt erstellen</button>
</div>
<tt-table-crud emit-edit
ref="table"
:additional-actions="additionalActions"
@edit="openProjectModal($event.id)">
<template v-slot:expandedRow="{ row }">
<warehouse-project-details :project="row" @refresh="$refs.table.$refs.table.refreshTable()"/>
</template>
</tt-table-crud>
<warehouse-project-modal
v-if="projectModalId"
:id="projectModalId"
@close="closeProjectModal"
/>
</tt-card>
`,
data() {
return {window: window}
return {
additionalActions: [],
projectModalId: null
};
},
methods: {
openProjectModal(id) {
this.projectModalId = id;
},
closeProjectModal() {
this.projectModalId = null;
this.$refs.table.$refs.table.refreshTable();
}
}
});
Vue.component('warehouse-project-details', {
props: ['project'],
template: `
<div class="my-3 bg-white p-1" style="cursor: default;">
<!-- Tabs Header -->
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-white border-0 p-2">
<ul class="nav nav-pills nav-fill">
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'overview'}" href="#" @click.prevent="activeTab = 'overview'"><i class="fas fa-info-circle mr-1"></i> Übersicht</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'tasks'}" href="#" @click.prevent="activeTab = 'tasks'"><i class="fas fa-tasks mr-1"></i> Aufgaben</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'team'}" href="#" @click.prevent="activeTab = 'team'"><i class="fas fa-users mr-1"></i> Team</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'logistics'}" href="#" @click.prevent="activeTab = 'logistics'"><i class="fas fa-truck-loading mr-1"></i> Logistik</a>
</li>
<li class="nav-item">
<a class="nav-link font-weight-bold" :class="{active: activeTab === 'journal'}" href="#" @click.prevent="activeTab = 'journal'"><i class="fas fa-book mr-1"></i> Journal</a>
</li>
</ul>
</div>
</div>
<!-- Tabs Content -->
<div class="">
<!-- OVERVIEW -->
<div v-if="activeTab === 'overview'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-6 border-right">
<h5 class="card-title text-primary border-bottom pb-2 mb-3">Projektdaten</h5>
<dl class="row">
<dt class="col-sm-4 text-muted">Status</dt>
<dd class="col-sm-8">
<span :class="statusBadgeClass(project.status)">{{ statusText(project.status) }}</span>
</dd>
<dt class="col-sm-4 text-muted">Projekt-Nr.</dt>
<dd class="col-sm-8 font-weight-bold">{{ project.projectNumber }}</dd>
<dt class="col-sm-4 text-muted">Zeitraum</dt>
<dd class="col-sm-8">
<i class="far fa-calendar-alt text-info mr-1"></i> {{ formatDate(project.startDate) }} - {{ formatDate(project.endDate) }}
</dd>
<dt class="col-sm-4 text-muted">Lagerort</dt>
<dd class="col-sm-8">{{ project.storageLocation || '-' }}</dd>
<dt class="col-sm-4 text-muted">Budget</dt>
<dd class="col-sm-8 font-weight-bold text-success">{{ formatPrice(project.financials) }}</dd>
</dl>
</div>
<div class="col-md-6">
<h5 class="card-title text-primary border-bottom pb-2 mb-3">Details</h5>
<p class="text-uppercase text-secondary small font-weight-bold mb-1">Beschreibung</p>
<div class="p-3 bg-light rounded mb-4 border" style="min-height: 80px;">{{ project.description || 'Keine Beschreibung vorhanden.' }}</div>
<p class="text-uppercase text-secondary small font-weight-bold mb-1">Externes Team / Partner</p>
<div class="p-3 bg-light rounded border">{{ project.externalTeam || '-' }}</div>
</div>
</div>
</div>
</div>
<!-- TASKS -->
<div v-if="activeTab === 'tasks'" class="card shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4 bg-light p-3 rounded border">
<div class="d-flex align-items-center flex-grow-1 mr-4">
<span class="mr-3 font-weight-bold text-muted">Fortschritt:</span>
<div class="progress flex-grow-1 shadow-sm" style="height: 25px;">
<div class="progress-bar bg-success progress-bar-striped progress-bar-animated" role="progressbar" :style="{width: taskProgress + '%'}" :aria-valuenow="taskProgress" aria-valuemin="0" aria-valuemax="100">
{{ taskProgress }}%
</div>
</div>
</div>
<button class="btn btn-success shadow" @click="openTaskModal()"><i class="fas fa-plus-circle"></i> Neue Aufgabe</button>
</div>
<div class="row">
<!-- TODO Column -->
<div class="col-md-4">
<div class="bg-secondary-light p-3 rounded h-100 border shadow-sm" style="background-color: #f8f9fa;">
<h6 class="text-uppercase text-secondary font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="far fa-circle mr-1"></i> Zu Erledigen
<span class="badge badge-secondary badge-pill ml-1">{{ tasksByStatus('todo').length }}</span>
</h6>
<div v-for="task in tasksByStatus('todo')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-warning" style="border-left-width: 5px;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark">{{ task.title }}</h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="openTaskModal(task)"><i class="fas fa-edit mr-2 text-info"></i> Bearbeiten</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'in_progress')"><i class="fas fa-play mr-2 text-primary"></i> Starten</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="#" @click.prevent="deleteTask(task.id)"><i class="fas fa-trash-alt mr-2"></i> Löschen</a>
</div>
</div>
</div>
<p class="card-text small text-muted mb-2 mt-1" v-if="task.description">{{ task.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-muted"><i class="fas fa-user-circle mr-1"></i> {{ task.assignedUserName || 'Niemand' }}</small>
</div>
</div>
</div>
<div v-if="tasksByStatus('todo').length === 0" class="text-center text-muted small mt-5 p-4 border border-dashed rounded">Keine Aufgaben</div>
</div>
</div>
<!-- IN PROGRESS Column -->
<div class="col-md-4">
<div class="bg-primary-light p-3 rounded h-100 border shadow-sm" style="background-color: #eef5ff;">
<h6 class="text-uppercase text-primary font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="fas fa-spinner fa-spin mr-1"></i> In Arbeit
<span class="badge badge-primary badge-pill ml-1">{{ tasksByStatus('in_progress').length }}</span>
</h6>
<div v-for="task in tasksByStatus('in_progress')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-primary" style="border-left-width: 5px;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark">{{ task.title }}</h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="openTaskModal(task)"><i class="fas fa-edit mr-2 text-info"></i> Bearbeiten</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'done')"><i class="fas fa-check mr-2 text-success"></i> Erledigen</a>
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'todo')"><i class="fas fa-undo mr-2 text-secondary"></i> Zurückstellen</a>
</div>
</div>
</div>
<p class="card-text small text-muted mb-2 mt-1" v-if="task.description">{{ task.description }}</p>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-primary font-weight-bold"><i class="fas fa-user-circle mr-1"></i> {{ task.assignedUserName || 'Niemand' }}</small>
</div>
</div>
</div>
</div>
</div>
<!-- DONE Column -->
<div class="col-md-4">
<div class="bg-success-light p-3 rounded h-100 border shadow-sm" style="background-color: #e8f5e9;">
<h6 class="text-uppercase text-success font-weight-bold text-center mb-3 border-bottom pb-2">
<i class="fas fa-check-circle mr-1"></i> Erledigt
<span class="badge badge-success badge-pill ml-1">{{ tasksByStatus('done').length }}</span>
</h6>
<div v-for="task in tasksByStatus('done')" :key="task.id" class="card mb-3 shadow-sm task-card border-left-success" style="border-left-width: 5px; opacity: 0.85;">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start">
<h6 class="card-title mb-1 font-weight-bold text-dark"><del>{{ task.title }}</del></h6>
<div class="dropdown">
<button class="btn btn-link btn-sm p-0 text-muted" type="button" data-toggle="dropdown"><i class="fas fa-ellipsis-v"></i></button>
<div class="dropdown-menu dropdown-menu-right shadow">
<a class="dropdown-item" href="#" @click.prevent="updateTaskStatus(task.id, 'in_progress')"><i class="fas fa-undo mr-2 text-primary"></i> Wieder öffnen</a>
<a class="dropdown-item text-danger" href="#" @click.prevent="deleteTask(task.id)"><i class="fas fa-trash-alt mr-2"></i> Löschen</a>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3 pt-2 border-top">
<small class="text-success"><i class="fas fa-check mr-1"></i> Abgeschlossen</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TEAM -->
<div v-if="activeTab === 'team'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow border-0">
<div class="card-header bg-white text-dark d-flex justify-content-between align-items-center border-bottom">
<h5 class="mb-0 font-weight-bold"><i class="fas fa-users mr-2 text-primary"></i> Projektteam</h5>
<span class="badge badge-pill badge-primary">{{ team.length }} Mitglieder</span>
</div>
<div class="card-body bg-light">
<div class="input-group mb-4 shadow-sm bg-white rounded">
<tt-select
v-model="selectedUserId"
:options="allUsersSelectItems"
placeholder="Mitarbeiter auswählen"
class="form-control border-0"
style="flex: 1;"
></tt-select>
<div class="input-group-append">
<button class="btn btn-success font-weight-bold" @click="addMember" :disabled="!selectedUserId"><i class="fas fa-user-plus mr-1"></i></button>
</div>
</div>
<ul class="list-group list-group-flush rounded shadow-sm">
<li v-for="member in team" :key="member.memberId" class="list-group-item d-flex justify-content-between align-items-center border-bottom hover-bg-light">
<div class="d-flex align-items-center">
<div class="avatar-circle bg-gradient-primary text-white mr-3 d-flex align-items-center justify-content-center shadow-sm"
style="width: 45px; height: 45px; border-radius: 50%; font-weight: bold; font-size: 1.1rem; background: linear-gradient(45deg, #007bff, #0056b3);">
{{ member.name.charAt(0) }}
</div>
<div>
<h6 class="mb-0 font-weight-bold text-dark">{{ member.name }}</h6>
<small class="text-muted"><i class="fas fa-id-badge mr-1"></i> {{ member.role || 'Projektmitarbeiter' }}</small>
</div>
</div>
<button class="btn btn-outline-danger btn-sm rounded-circle shadow-sm" @click="removeMember(member.memberId)" title="Entfernen" style="width: 32px; height: 32px;"><i class="fas fa-times"></i></button>
</li>
<li v-if="team.length === 0" class="list-group-item text-center text-muted py-5">
<div class="opacity-50">
<i class="fas fa-users fa-4x mb-3 text-gray-300"></i>
<p class="lead">Noch keine Teammitglieder zugewiesen.</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LOGISTICS -->
<div v-if="activeTab === 'logistics'" class="card shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4 bg-light p-3 rounded border shadow-sm">
<h5 class="mb-0 text-dark"><i class="fas fa-boxes mr-2"></i> Logistik & Bestellungen</h5>
<button class="btn btn-primary shadow" @click="openLinkOrderModal"><i class="fas fa-link mr-1"></i> Bestellwunsch verknüpfen</button>
</div>
<div class="accordion" id="logisticsAccordion">
<div v-for="request in linkedOrders" :key="request.linkId" class="card mb-3 shadow-sm border-0">
<div class="card-header bg-white d-flex justify-content-between align-items-center pointer border rounded"
:id="'heading' + request.linkId"
data-toggle="collapse"
:data-target="'#collapse' + request.linkId"
aria-expanded="true"
:aria-controls="'collapse' + request.linkId"
style="transition: background-color 0.2s;">
<div class="d-flex align-items-center">
<span class="fa-stack mr-2 text-primary">
<i class="fas fa-circle fa-stack-2x opacity-25"></i>
<i class="fas fa-hashtag fa-stack-1x"></i>
</span>
<div>
<span class="font-weight-bold text-dark">{{ request.requestId }}</span>
<span class="mx-2 text-muted">|</span>
<span class="font-weight-500">{{ request.purpose }}</span>
</div>
</div>
<div class="d-flex align-items-center">
<span v-if="request.status === 'done'" class="badge badge-success px-3 py-2 shadow-sm"><i class="fas fa-check mr-1"></i> Erledigt</span>
<span v-else-if="request.status === 'cancelled'" class="badge badge-danger px-3 py-2 shadow-sm"><i class="fas fa-ban mr-1"></i> Storniert</span>
<span v-else class="badge badge-info px-3 py-2 shadow-sm"><i class="fas fa-clock mr-1"></i> Offen</span>
<button class="btn btn-sm btn-outline-danger ml-3 rounded-circle shadow-sm" style="width: 30px; height: 30px;" @click.stop="unlinkOrder(request.linkId)" title="Verknüpfung lösen"><i class="fas fa-unlink"></i></button>
<i class="fas fa-chevron-down ml-3 text-muted"></i>
</div>
</div>
<div :id="'collapse' + request.linkId" class="collapse show" :aria-labelledby="'heading' + request.linkId">
<div class="card-body bg-light border-left border-right border-bottom p-0">
<div class="p-3">
<h6 class="text-muted text-uppercase small font-weight-bold mb-3 pl-2 border-left-primary" style="border-left: 3px solid #007bff;">Enthaltene Bestellungen</h6>
<div v-if="request.orders && request.orders.length > 0">
<div class="table-responsive bg-white rounded shadow-sm border">
<table class="table table-hover mb-0">
<thead class="thead-light">
<tr>
<th class="border-top-0">Order Nr.</th>
<th class="border-top-0">Status</th>
<th class="border-top-0 text-right">Aktion</th>
</tr>
</thead>
<tbody>
<tr v-for="order in request.orders" :key="order.id">
<td class="align-middle"><a :href="'/WarehouseOrder?id=' + order.id" target="_blank" class="font-weight-bold text-primary"><i class="fas fa-file-invoice mr-1"></i> {{ order.orderNumber }}</a></td>
<td class="align-middle">
<span class="badge badge-light border text-secondary px-2 py-1">{{ order.status || 'Unbekannt' }}</span>
</td>
<td class="align-middle text-right">
<a :href="'/WarehouseOrder?id=' + order.id" target="_blank" class="btn btn-sm btn-light border shadow-sm"><i class="fas fa-external-link-alt text-secondary"></i> Öffnen</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="text-muted small font-italic p-3 text-center">
Keine direkten Bestellungen verknüpft oder Daten noch nicht verfügbar.
</div>
</div>
</div>
</div>
</div>
<div v-if="linkedOrders.length === 0" class="text-center text-muted py-5 border rounded bg-light shadow-sm">
<i class="fas fa-truck-loading fa-4x mb-3 text-gray-300"></i>
<p class="h5 font-weight-light">Keine verknüpften Bestellwünsche.</p>
<button class="btn btn-outline-primary mt-2 btn-sm" @click="openLinkOrderModal">Jetzt verknüpfen</button>
</div>
</div>
</div>
</div>
<!-- JOURNAL -->
<div v-if="activeTab === 'journal'" class="card shadow-sm border-0">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<!-- Stylish Input Area -->
<div class="card shadow-sm mb-3 border-0 bg-light">
<div class="card-body p-2">
<div class="d-flex align-items-start">
<div class="avatar-circle bg-primary text-white mr-3 d-flex align-items-center justify-content-center rounded-circle mt-1 shadow-sm" style="width: 35px; height: 35px; font-size: 0.9rem;">
<i class="fas fa-pen"></i>
</div>
<div class="flex-grow-1 position-relative">
<textarea v-model="newJournalMessage"
class="form-control border-0 bg-white shadow-sm"
rows="2"
style="resize: none; border-radius: 1rem; padding: 10px; font-size: 0.9rem;"
placeholder="Schreiben Sie eine Notiz..."></textarea>
<button class="btn btn-primary rounded-circle shadow position-absolute"
style="bottom: -10px; right: 10px; width: 35px; height: 35px; display: flex; align-items: center; justify-content: center;"
@click="postJournal"
:disabled="!newJournalMessage.trim()">
<i class="fas fa-paper-plane small"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Scrollable Feed -->
<div class="journal-feed pr-2" style="max-height: 500px; overflow-y: auto;">
<div v-for="log in journal" :key="log.id" class="media mb-3 p-2 bg-white shadow-sm rounded border-left-info position-relative" style="border-left: 3px solid #17a2b8;">
<div class="mr-2 text-center" style="width: 45px;">
<div class="text-muted small font-weight-bold" style="font-size: 0.75rem;">{{ new Date(log.create * 1000).toLocaleDateString('de-DE', {day: '2-digit', month: '2-digit'}) }}</div>
<div class="text-muted small" style="font-size: 0.7rem;">{{ new Date(log.create * 1000).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) }}</div>
</div>
<div class="media-body">
<h6 class="mt-0 mb-1 font-weight-bold text-dark d-flex justify-content-between" style="font-size: 0.9rem;">
{{ log.userName }}
</h6>
<div class="text-secondary" style="white-space: pre-wrap; font-size: 0.85rem; line-height: 1.4;">{{ log.text }}</div>
</div>
</div>
<div v-if="journal.length === 0" class="text-center text-muted py-4 opacity-50">
<i class="fas fa-history fa-2x mb-2 text-gray-300"></i>
<p class="small">Noch keine Journal-Einträge.</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow border-0">
<div class="card-header bg-secondary text-white py-2">
<h6 class="mb-0 small"><i class="fas fa-paperclip mr-1"></i> Dokumente</h6>
</div>
<div class="card-body text-center text-muted bg-light" style="min-height: 150px; display: flex; flex-direction: column; justify-content: center;">
<i class="fas fa-cloud-upload-alt fa-2x mb-2 text-gray-400"></i>
<p class="small mb-0">Dateien hierher ziehen</p>
<p class="small text-muted font-italic" style="font-size: 0.7rem;">(WIP: Upload Funktion)</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Task Modal -->
<div class="modal fade" id="taskModal" tabindex="-1" role="dialog" ref="taskModal">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold"><i class="fas fa-tasks mr-2"></i> {{ currentTask.id ? 'Aufgabe bearbeiten' : 'Neue Aufgabe' }}</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body bg-light">
<form @submit.prevent="saveTask">
<div class="form-group">
<label class="font-weight-bold text-dark">Titel <span class="text-danger">*</span></label>
<input type="text" class="form-control shadow-sm" v-model="currentTask.title" required placeholder="Was muss erledigt werden?">
</div>
<div class="form-group">
<label class="font-weight-bold text-dark">Beschreibung</label>
<textarea class="form-control shadow-sm" v-model="currentTask.description" rows="4" placeholder="Details zur Aufgabe..."></textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="font-weight-bold text-dark">Zugewiesen an</label>
<select class="form-control shadow-sm" v-model="currentTask.assignedUserId">
<option value="">-- Unzugewiesen --</option>
<option v-for="u in allUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="font-weight-bold text-dark">Status</label>
<select class="form-control shadow-sm" v-model="currentTask.status">
<option value="todo">ToDo</option>
<option value="in_progress">In Arbeit</option>
<option value="done">Erledigt</option>
</select>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer bg-white">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary shadow" @click="saveTask">Speichern</button>
</div>
</div>
</div>
</div>
<!-- Link Order Modal -->
<div class="modal fade" id="linkOrderModal" tabindex="-1" role="dialog" ref="linkOrderModal">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title font-weight-bold"><i class="fas fa-link mr-2"></i> Bestellwunsch verknüpfen</h5>
<button type="button" class="close text-white" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body bg-light" style="max-height: 60vh; overflow-y: auto;">
<div v-if="availableOrders.length === 0" class="alert alert-info shadow-sm text-center py-4">
<i class="fas fa-info-circle fa-2x mb-3"></i>
<p class="mb-0">Keine offenen Bestellwünsche gefunden.</p>
</div>
<div v-else class="list-group shadow-sm">
<button v-for="req in availableOrders" :key="req.id" type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center p-3 hover-bg-light" @click="linkOrder(req.id)">
<div>
<h6 class="mb-1 font-weight-bold text-primary">#{{ req.id }} - {{ req.purpose }}</h6>
<small class="text-muted"><i class="far fa-clock mr-1"></i> Erstellt am: {{ formatDateTime(req.create) }}</small>
</div>
<i class="fas fa-plus-circle text-success fa-2x"></i>
</button>
</div>
</div>
<div class="modal-footer bg-white">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Schließen</button>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
activeTab: 'overview',
tasks: [],
team: [],
linkedOrders: [],
journal: [],
allUsers: [],
availableOrders: [],
selectedUserId: '',
newJournalMessage: '',
currentTask: {
id: null,
title: '',
description: '',
assignedUserId: '',
status: 'todo'
}
};
},
computed: {
taskProgress() {
if (!this.tasks || !this.tasks.length) return 0;
const done = this.tasks.filter(t => t.status === 'done').length;
return Math.round((done / this.tasks.length) * 100);
},
allUsersSelectItems() {
return this.allUsers.map(u => ({ value: u.id, text: u.name }));
}
},
mounted() {
this.loadData();
},
methods: {
async loadData() {
try {
await Promise.all([
this.fetchTasks(),
this.fetchTeam(),
this.fetchLinkedOrders(),
this.fetchJournal(),
this.fetchUsers()
]);
} catch (e) {
console.error("Error loading project data", e);
}
},
async fetchTasks() {
try {
const res = await axios.get('/WarehouseProject/getTasks?id=' + this.project.id);
this.tasks = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.tasks = [];
}
},
async fetchTeam() {
try {
const res = await axios.get('/WarehouseProject/getTeam?id=' + this.project.id);
this.team = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.team = [];
}
},
async fetchLinkedOrders() {
try {
const res = await axios.get('/WarehouseProject/getLinkedOrders?id=' + this.project.id);
this.linkedOrders = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.linkedOrders = [];
}
},
async fetchJournal() {
try {
const res = await axios.get('/WarehouseProject/getJournal?id=' + this.project.id);
this.journal = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.journal = [];
}
},
async fetchUsers() {
try {
const res = await axios.get('/WarehouseProject/getUsers');
this.allUsers = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.allUsers = [];
}
},
async fetchAvailableOrders() {
try {
const res = await axios.get('/WarehouseProject/getAvailableOrderRequests');
this.availableOrders = Array.isArray(res.data) ? res.data : [];
} catch(e) {
this.availableOrders = [];
}
},
// Helpers
tasksByStatus(status) {
if (!this.tasks) return [];
return this.tasks.filter(t => t.status === status);
},
statusLabel(status) {
const map = { todo: 'ToDo', in_progress: 'In Arbeit', done: 'Erledigt' };
return map[status] || status;
},
statusText(status) {
const map = { new: 'Neu', wip: 'In Bearbeitung', finished: 'Abgeschlossen', cancelled: 'Storniert' };
return map[status] || status;
},
statusBadgeClass(status) {
const map = { new: 'badge badge-primary', wip: 'badge badge-warning', finished: 'badge badge-success', cancelled: 'badge badge-danger' };
return map[status] || 'badge badge-secondary';
},
formatDate(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleDateString('de-DE');
},
formatDateTime(ts) {
if (!ts) return '-';
return new Date(ts * 1000).toLocaleString('de-DE');
},
formatPrice(val) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val || 0);
},
// Actions
openTaskModal(task = null) {
if (task) {
this.currentTask = { ...task };
} else {
this.currentTask = { id: null, title: '', description: '', assignedUserId: '', status: 'todo' };
}
$(this.$refs.taskModal).modal('show');
},
async saveTask() {
if (!this.currentTask.title) return alert("Titel erforderlich");
try {
await axios.post('/WarehouseProject/saveTask', { ...this.currentTask, projectId: this.project.id });
$(this.$refs.taskModal).modal('hide');
this.fetchTasks();
this.fetchJournal();
} catch(e) {
console.error(e);
alert("Fehler beim Speichern: " + (e.response?.data?.error || 'Unbekannt'));
}
},
async updateTaskStatus(id, status) {
await axios.post('/WarehouseProject/updateTaskStatus', { id, status });
this.fetchTasks();
this.fetchJournal();
},
async deleteTask(id) {
if(!confirm("Wirklich löschen?")) return;
await axios.get('/WarehouseProject/deleteTask?id=' + id);
this.fetchTasks();
this.fetchJournal();
},
async addMember() {
if (!this.selectedUserId) return;
try {
await axios.post('/WarehouseProject/addTeamMember', { projectId: this.project.id, userId: this.selectedUserId });
this.fetchTeam();
this.fetchJournal();
this.selectedUserId = '';
} catch (e) {
alert("Fehler beim Hinzufügen (evtl. bereits im Team?)");
}
},
async removeMember(id) {
await axios.get('/WarehouseProject/removeTeamMember?id=' + id);
this.fetchTeam();
this.fetchJournal();
},
async openLinkOrderModal() {
await this.fetchAvailableOrders();
$(this.$refs.linkOrderModal).modal('show');
},
async linkOrder(orderId) {
if (!orderId) return;
try {
await axios.post('/WarehouseProject/linkOrder', { projectId: this.project.id, orderId });
$(this.$refs.linkOrderModal).modal('hide');
this.fetchLinkedOrders();
this.fetchJournal();
} catch (e) {
alert("Fehler beim Verknüpfen");
}
},
async unlinkOrder(id) {
await axios.get('/WarehouseProject/unlinkOrder?id=' + id);
this.fetchLinkedOrders();
this.fetchJournal();
},
async postJournal() {
if (!this.newJournalMessage.trim()) return;
await axios.post('/WarehouseProject/createJournalEntry', { projectId: this.project.id, message: this.newJournalMessage });
this.newJournalMessage = '';
this.fetchJournal();
}
}
});

View File

@@ -0,0 +1,146 @@
Vue.component('warehouse-project-modal', {
props: {
id: { type: [String, Number], required: true },
},
data() {
return {
window: window,
loading: false,
allUsers: [],
project: {
title: '',
description: '',
startDate: null,
endDate: null,
storageLocation: '',
externalTeam: '',
internalTeam: [], // Array of user IDs
status: 'new',
financials: 0.00
},
statusOptions: [
{ text: 'Neu', value: 'new' },
{ text: 'In Bearbeitung', value: 'wip' },
{ text: 'Abgeschlossen', value: 'finished' },
{ text: 'Storniert', value: 'cancelled' }
]
};
},
//language=Vue
template: `
<tt-modal :show="true"
:delete="false"
:title="id === 'create' ? 'Projekt erstellen' : 'Projekt bearbeiten'"
@update:show="$emit('close')"
@submit="submit"
:save-loading="loading"
>
<div style="width: 99%">
<tt-input v-model="project.title" label="Bezeichnung *" sm row required/>
<div class="row">
<div class="col-md-6">
<tt-input v-model="project.startDate" label="Startdatum *" type="date" sm row required/>
</div>
<div class="col-md-6">
<tt-input v-model="project.endDate" label="Enddatum *" type="date" sm row required/>
</div>
</div>
<tt-input v-model="project.storageLocation" label="Lagerort" sm row/>
<div class="form-group row">
<label class="col-sm-3 col-form-label col-form-label-sm">Status *</label>
<div class="col-sm-9">
<select class="form-control form-control-sm" v-model="project.status" required>
<option v-for="opt in statusOptions" :key="opt.value" :value="opt.value">{{ opt.text }}</option>
</select>
</div>
</div>
<!-- Internal Team Select (Create Only or simplified list) -->
<div class="form-group row" v-if="id === 'create'">
<label class="col-sm-3 col-form-label col-form-label-sm">Internes Team</label>
<div class="col-sm-9">
<select class="form-control form-control-sm" v-model="project.internalTeam" multiple style="height: 100px;">
<option v-for="u in allUsers" :key="u.id" :value="u.id">{{ u.name }}</option>
</select>
<small class="text-muted">Strg+Klick für Mehrfachauswahl</small>
</div>
</div>
<tt-textarea v-model="project.externalTeam" label="Externes Team" sm row placeholder="Namen, Firmen, Kontakte..."/>
<tt-textarea v-model="project.description" label="Beschreibung" sm row rows="4"/>
<div v-if="id !== 'create'" class="form-group row">
<label class="col-sm-3 col-form-label col-form-label-sm">Finanzen</label>
<div class="col-sm-9">
<input type="text" class="form-control form-control-sm" :value="formatPrice(project.financials)" disabled>
<small class="text-muted">Wird automatisch berechnet.</small>
</div>
</div>
</div>
</tt-modal>
`,
async mounted() {
this.fetchUsers();
if (this.id !== 'create') {
this.loading = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/getById`, { params: { id: this.id } });
if (data) {
this.project = { ...this.project, ...data };
// Ensure dates are in YYYY-MM-DD format for input type="date"
if (this.project.startDate) this.project.startDate = this.formatDateForInput(this.project.startDate);
if (this.project.endDate) this.project.endDate = this.formatDateForInput(this.project.endDate);
}
} catch (e) {
console.error(e);
window.notify('error', 'Projekt konnte nicht geladen werden.');
} finally {
this.loading = false;
}
}
},
methods: {
async fetchUsers() {
try {
const res = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/getUsers`);
this.allUsers = res.data;
} catch(e) { console.error(e); }
},
formatDateForInput(timestamp) {
if (!timestamp) return null;
return new Date(timestamp * 1000).toISOString().split('T')[0];
},
formatPrice(val) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(val || 0);
},
async submit() {
if (!this.project.title || !this.project.startDate || !this.project.endDate) {
return window.notify('error', 'Bitte füllen Sie alle Pflichtfelder aus (*).');
}
this.loading = true;
try {
const payload = { ...this.project };
if (payload.startDate) payload.startDate = Math.floor(new Date(payload.startDate).getTime() / 1000);
if (payload.endDate) payload.endDate = Math.floor(new Date(payload.endDate).getTime() / 1000);
const url = `${window.TT_CONFIG["BASE_PATH"]}/WarehouseProject/${this.id === 'create' ? 'create' : 'update'}`;
const response = await axios.post(url, payload);
if (response.data.success) {
this.$emit('close');
window.notify('success', response.data.message || 'Gespeichert.');
} else {
window.notify('error', response.data.message || 'Fehler beim Speichern.');
}
} catch (e) {
console.error(e);
window.notify('error', 'Ein Systemfehler ist aufgetreten.');
} finally {
this.loading = false;
}
}
}
});

View File

@@ -364,6 +364,7 @@ Vue.component('tt-table', {
const fetchTimestamp = Date.now();
this.latestFetchTimestamp = fetchTimestamp;
console.log(this.fetchUrl);
const response = await axios.post(this.fetchUrl, {
pagination: {
page: Math.max(page, 1), per_page: this.pagination?.per_page ? this.pagination.per_page : 10,