Merge branch 'ConstructionConsent/add-signature' into 'master'

added new signature action

See merge request fronk/thetool!1340
This commit is contained in:
Luca Haid
2025-05-15 12:02:48 +00:00
7 changed files with 656 additions and 4 deletions

View File

@@ -679,4 +679,85 @@ FROM ConstructionConsent
return $where;
}
public static function searchConstructionConsentBasedOnName($name, $filter = []) {
$items = [];
$db = FronkDB::singleton();
$searchName = trim($name);
if (empty($searchName)) {
// return $items;
}
$searchTerms = explode(' ', $searchName);
$searchConditions = [];
foreach ($searchTerms as $term) {
$escapedTerm = $db->escape($term);
$searchConditions[] = "(cco.firstname LIKE '%$escapedTerm%' OR
cco.lastname LIKE '%$escapedTerm%' OR
cc.name LIKE '%$escapedTerm%' OR
ccp.name LIKE '%$escapedTerm%')";
}
if (!empty($searchConditions)) {
$intelligentSearchWhere = "(" . implode(' AND ', $searchConditions) . ")";
if (isset($filter['add-where'])) {
$filter['add-where'] .= " AND $intelligentSearchWhere";
} else {
$filter['add-where'] = "AND $intelligentSearchWhere";
}
}
if (isset($filter['add-where'])) {
$filter['add-where'] .= " AND cco.firstname IS NOT NULL";
} else {
$filter['add-where'] = "AND cco.firstname IS NOT NULL";
}
$where = self::getSqlFilter($filter);
$sql = "SELECT cc.id AS consent_id, cc.name AS consent_name, cc.status AS consent_status,
cc.result AS consent_result, cc.ez, cc.kg, cc.gst, cc.gstnr,
ccp.id AS project_id, ccp.name AS project_name,
cco.id AS owner_id, cco.firstname, cco.lastname, cco.street, cco.zip, cco.city,
cco.email, cco.phone, cco.status AS owner_status, cco.result AS owner_result,
vh.strasse AS address_street, vh.hausnummer AS address_number,
vh.plz AS address_zip, vh.ortschaft AS address_city
FROM ConstructionConsent cc
LEFT JOIN ConstructionConsentProject ccp ON cc.constructionconsentproject_id = ccp.id
LEFT JOIN ConstructionConsentOwner cco ON cc.id = cco.constructionconsent_id
LEFT JOIN `".ADDRESSDB_DBNAME."`.view_hausnummer vh ON cc.adb_hausnummer_id = vh.hausnummer_id
WHERE $where
ORDER BY cc.edit DESC, cco.lastname ASC, cco.firstname ASC";
$res = $db->query($sql);
while ($data = $res->fetch_assoc()) {
$items[] = [
'consent_id' => $data['consent_id'],
'consent_name' => $data['consent_name'],
'consent_status' => $data['consent_status'],
'consent_result' => $data['consent_result'],
'project_id' => $data['project_id'],
'project_name' => $data['project_name'],
'owner_id' => $data['owner_id'],
'owner_name' => trim($data['firstname'] . ' ' . $data['lastname']),
'owner_address' => $data['street'] . ', ' . $data['zip'] . ' ' . $data['city'],
'owner_contact' => $data['email'] . ($data['phone'] ? ' / ' . $data['phone'] : ''),
'owner_status' => $data['owner_status'],
'owner_result' => $data['owner_result'],
'property_info' => 'EZ:' . $data['ez'] . ' KG:' . $data['kg'] . ' GST:' . $data['gst'] . ($data['gstnr'] ? '-' . $data['gstnr'] : ''),
'address' => $data['address_street'] . ' ' . $data['address_number'] . ', ' . $data['address_zip'] . ' ' . $data['address_city'],
];
}
return $items;
}
}

View File

@@ -168,6 +168,7 @@ class ConstructionConsentController extends mfBaseController {
protected function downloadAction()
{
$owner_id = $this->request->owner_id;
$open = $this->request->open;
if (!is_numeric($owner_id) || $owner_id < 1 || !($owner = new ConstructionConsentOwner($owner_id))->id) {
$this->layout()->setFlash("Besitzer nicht gefunden", "error");
@@ -184,7 +185,7 @@ class ConstructionConsentController extends mfBaseController {
$this->redirect("ConstructionConsent", "View", ["id" => $cc->id]);
}
$this->sendPdfResponse($filename, "Zustimmungserklärung-{$cc->id}-{$owner_id}.pdf");
$this->sendPdfResponse($filename, "Zustimmungserklärung-{$cc->id}-{$owner_id}.pdf", $open === 'true');
}
protected function downloadMultipleAction()
@@ -224,10 +225,12 @@ class ConstructionConsentController extends mfBaseController {
$this->sendPdfResponse($filename, "Zustimmungserklärungen-".date('Y-m-d').".pdf");
}
private function sendPdfResponse(string $filename, string $downloadName): void
private function sendPdfResponse(string $filename, string $downloadName, bool $open = false): void
{
header('Content-Type: ' . mime_content_type($filename));
header('Content-disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
if ($open === true) header('Content-disposition: inline; filename="' . rawurlencode($downloadName) . '"');
else header('Content-disposition: attachment; filename="' . rawurlencode($downloadName) . '"');
header('Content-Length: ' . filesize($filename));
readfile($filename);
exit;
@@ -1278,4 +1281,84 @@ class ConstructionConsentController extends mfBaseController {
fclose($output);
exit();
}
protected function searchTabletAction() {
$name = '';
$filter = [];
// Handle POST data - support both form data and JSON input
if($_SERVER['REQUEST_METHOD'] === 'POST') {
$postData = [];
// Check for JSON input (Axios sends JSON)
$inputJSON = file_get_contents('php://input');
$jsonData = json_decode($inputJSON, true);
if($jsonData !== null) {
$postData = $jsonData;
} else {
$postData = $_POST;
}
if(isset($postData['name'])) {
$name = $postData['name'];
}
if(isset($postData['filter']) && is_array($postData['filter'])) {
$filter = $postData['filter'];
}
}
$results = ConstructionConsent::searchConstructionConsentBasedOnName($name, $filter);
self::returnJson([
'success' => true,
'count' => count($results),
'results' => $results
]);
}
protected function signTabletAction() {
$JSGlobals = ["BASE_PATH" => self::getUrl(""),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
"PAGE_TITLE" => "Zustimmungserklärungen unterzeichnen",
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
["text" => "Zustimmungserklärungen unterzeichnen", "href" => self::getUrl("ConstructionConsent", "SignTablet")],
],
"IS_ADMIN" => $this->me->is("Admin"),
];
$this->layout()->set("vueViewName", "ConstructionConsentSignTablet");
$this->layout()->set("JSGlobals", $JSGlobals);
$this->layout()->setTemplate("VueViews/Vue");
}
protected function signAction() {
$POST = json_decode(file_get_contents('php://input'), true);
$owner_id = $POST['owner_id'] ?? null;
$signature = $POST['signature'] ?? null;
$signature_name = $POST['signature_name'] ?? null;
$signature_date = $POST['signature_date'] ?? null;
if(!$owner_id || !$signature) {
self::sendError("Invalid request");
}
$owner = new ConstructionConsentOwner($owner_id);
if(!$owner->id) self::sendError("Owner not found");
if($signature_name) $owner->signature_name = $signature_name;
if($signature_date) $owner->signature_date = date("U", strtotime($signature_date));
if($signature) $owner->signature = $signature;
$owner->status = 'returned';
$owner->result = 'accepted';
if(!$owner->save()) self::sendError("Error saving signature");
self::returnJson(["success" => true]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class ConstrConsOwnerAddSignature extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "addressdb") {
$ConstructionConsentOwner = $this->table("ConstructionConsentOwner");
$ConstructionConsentOwner->addColumn("signature", "text", ["null" => true]);
$ConstructionConsentOwner->addColumn("signature_name", "string", ["limit" => 255, "null" => true]);
$ConstructionConsentOwner->addColumn("signature_date", "integer", ["null" => true]);
$ConstructionConsentOwner->update();
}
}
public function down(): void
{
if($this->getEnvironment() == "addressdb") {
$ConstructionConsentOwner = $this->table("ConstructionConsentOwner");
$ConstructionConsentOwner->removeColumn("signature");
$ConstructionConsentOwner->removeColumn("signature_name");
$ConstructionConsentOwner->removeColumn("signature_date");
$ConstructionConsentOwner->update();
}
}
}

View File

@@ -0,0 +1,83 @@
.consent-signing-container {
padding: 15px;
}
.signatureModal .modal-dialog {
max-width: 90%;
}
.signature-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.signature-header {
text-align: center;
margin-bottom: 20px;
width: 100%;
}
.signature-options {
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: 15px;
}
.signature-area {
width: 100%;
margin-bottom: 20px;
border: 1px solid #ccc;
border-radius: 5px;
overflow: hidden;
background-color: #f9f9f9;
}
.signature-area.landscape {
height: 300px;
}
.signature-area.portrait {
height: 500px;
}
.signature-actions {
display: flex;
justify-content: center;
gap: 10px;
width: 100%;
}
.notes-area {
width: 100%;
}
/* Touch-friendly styling for tablets */
@media (max-width: 1024px) {
.btn, .form-control, select {
height: 46px;
padding: 10px 15px;
font-size: 16px;
}
.btn-sm {
height: 38px;
padding: 8px 12px;
}
.table th, .table td {
padding: 12px 8px;
font-size: 14px;
}
input[type=range] {
height: 30px;
}
input[type=color] {
height: 30px;
width: 50px;
}
}

View File

@@ -0,0 +1,349 @@
Vue.component('construction-consent-signature-pad', {
props: {
ownerId: {type: Number, required: true},
ownerName: {type: String, required: true}
},
data() {
return {
window: window,
signaturePad: null,
consent: null,
signatureDate: new Date().toLocaleDateString(),
notes: '',
penWidth: 2,
penColor: '#000000',
screenOrientation: 'landscape'
}
},
//language=Vue
template: `
<tt-modal class="signatureModal" :show="true" :delete="false" :save="false" @update:show="$emit('close')" :title="'Unterschrift für Bauvorhaben'">
<div class="signature-container">
<div class="signature-header">
<h4>Einwilligung für {{ ownerName }}</h4>
<p>Datum: {{ signatureDate }}</p>
</div>
<div class="signature-area" :class="screenOrientation">
<canvas id="consent-signature-pad" width="800" height="300"></canvas>
</div>
<div class="notes-area mb-3">
<tt-input v-model="notes" label="Unterschrieben von...." placeholder="Name oder i.V."></tt-input>
</div>
<div class="signature-actions">
<button class="btn btn-primary" @click="submit()">Speichern</button>
<button class="btn btn-outline-secondary" @click="signaturePad.clear()">Zurücksetzen</button>
<button class="btn btn-outline-danger" @click="$emit('close')">Abbrechen</button>
</div>
</div>
</tt-modal>
`,
methods: {
async submit() {
try {
if (this.signaturePad.isEmpty()) {
this.window.notify('error', 'Bitte eine Unterschrift hinzufügen');
return;
}
const data = this.signaturePad.toDataURL();
const response = await axios.post(window.TT_CONFIG['BASE_PATH'] + '/ConstructionConsent/sign', {
owner_id: this.ownerId,
signature: data,
signature_name: this.notes,
signature_date: this.signatureDate
});
if (response.data.success) {
this.window.notify('success', response.data.message || 'Unterschrift erfolgreich gespeichert');
this.$emit('close', true); // Pass true to indicate successful signing
} else {
this.window.notify('error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message || 'Ein Fehler ist aufgetreten');
}
} catch (error) {
console.error('Error submitting signature:', error);
this.window.notify('error', 'Ein Fehler ist beim Speichern der Unterschrift aufgetreten');
}
},
updatePenSettings() {
this.signaturePad.penColor = this.penColor;
this.signaturePad.penWidth = this.penWidth;
},
toggleOrientation() {
this.screenOrientation = this.screenOrientation === 'landscape' ? 'portrait' : 'landscape';
this.$nextTick(() => {
this.resizeCanvas();
});
},
resizeCanvas() {
const canvas = document.getElementById('consent-signature-pad');
const container = canvas.parentElement;
if (this.screenOrientation === 'landscape') {
canvas.width = container.offsetWidth - 20;
canvas.height = 300;
} else {
canvas.width = Math.min(container.offsetWidth - 20, 500);
canvas.height = 500;
}
// Important: maintain drawing after resize
const data = this.signaturePad.toData();
this.signaturePad.clear();
if (data) {
this.signaturePad.fromData(data);
}
}
},
mounted() {
this.$nextTick(() => {
const canvas = document.getElementById('consent-signature-pad');
this.signaturePad = new SignaturePad(canvas, {
penColor: this.penColor,
penWidth: this.penWidth,
velocityFilterWeight: 0.5 // Better for tablet input
});
// Initial canvas sizing
this.resizeCanvas();
// Resize on window change
window.addEventListener('resize', this.resizeCanvas);
// Handle tablet touch events better
canvas.addEventListener('touchstart', function(event) {
if (event.cancelable) {
event.preventDefault();
}
}, {passive: false});
});
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeCanvas);
}
});
// Main Component for Construction Consent Owner Signing
Vue.component('construction-consent-sign-tablet', {
data() {
return {
window: window,
searchName: '',
searchFilter: {
consent_status: '',
owner_result: ''
},
results: [],
loading: false,
showSignaturePad: false,
selectedConsent: null,
filterOptions: {
consent_status: [
{text: 'Alle Status', value: ''},
{text: 'Offen', value: 'open'},
{text: 'In Bearbeitung', value: 'in_progress'},
{text: 'Abgeschlossen', value: 'completed'}
],
owner_result: [
{text: 'Alle Ergebnisse', value: ''},
{text: 'Nicht signiert', value: ''},
{text: 'Zugestimmt', value: 'accepted'},
{text: 'Abgelehnt', value: 'rejected'}
]
},
tableConfig: {
key: 'ConstructionConsentTable',
tableHeader: 'Bauvorhaben Eigentümer',
defaultPageSize: 10,
headers: [
{text: 'Bauvorhaben', key: 'consent_name', sortable: false, filter: false, class: 'text-nowrap', priority: 9},
{text: 'Projekt', key: 'project_name', sortable: false,filter: false, class: 'text-nowrap', priority: 7},
{text: 'Eigentümer', key: 'owner_name', sortable: false,filter: false, class: 'text-nowrap', priority: 6},
{text: 'Adresse', key: 'owner_address', sortable: false,filter: false, class: '', priority: 5},
{text: 'Eigentümer Status', key: 'owner_status', sortable: false,filter: false, class: 'text-nowrap', priority: 3},
{text: 'Eigentümer Ergebnis', key: 'owner_result', sortable: false,filter: false, class: 'text-nowrap', priority: 2},
{text: 'Aktionen', key: 'actions', sortable: false,filter: false, class: 'text-nowrap text-center', priority: 1}
],
}
}
},
//language=Vue
template: `
<div class="consent-signing-container">
<tt-card>
<div class="search-area p-3">
<div style="display: grid; grid-template-columns: 4fr 1fr; grid-gap: 16px; justify-content: space-between; align-items: center;">
<tt-input
v-model="searchName"
label="Eigentümer suchen"
placeholder="Name eingeben..."
></tt-input>
<tt-button
style="margin-top: 10px"
@click="searchOwners"
text="Suchen"
additionalClass="btn-primary"
icon="fas fa-search"
:loading="loading"
></tt-button>
</div>
</div>
</tt-card>
<tt-card v-if="results.length > 0" class="mt-3">
<tt-table
:data="results"
:config="tableConfig"
excel-export
>
<template v-slot:owner_status="{ row }">
<span class="badge" :class="getOwnerStatusBadgeClass(row.owner_status)">
{{ getOwnerStatusText(row.owner_status) }}
</span>
</template>
<template v-slot:owner_result="{ row }">
<span class="badge" :class="getOwnerResultBadgeClass(row.owner_result)">
{{ getOwnerResultText(row.owner_result) }}
</span>
</template>
<template v-slot:actions="{ row }">
<div class="btn-group btn-group-sm">
<tt-button
@click="openSignaturePad(row)"
text="Unterschreiben"
:sm="true"
additionalClass="btn-primary"
icon="fas fa-signature"
:disabled="row.owner_result === 'accepted'"
></tt-button>
<a :href="window.TT_CONFIG['BASE_PATH'] + '/ConstructionConsent/view/?id=' + row.consent_id"
class="btn btn-sm btn-info"
target="_blank"
title="Ansehen">
<i class="far fa-eye"></i>
</a>
<a :href="window.TT_CONFIG['BASE_PATH'] + '/ConstructionConsent/Download/?open=true&owner_id=' + row.owner_id"
class="btn btn-sm btn-info"
target="_blank"
title="PDF">
<i class="fas fa-file-pdf"></i>
</a>
</div>
</template>
</tt-table>
</tt-card>
<tt-card v-else-if="results.length === 0 && !loading" class="mt-3">
<div class="alert alert-info">
Keine Ergebnisse gefunden. Bitte versuchen Sie eine andere Suche.
</div>
</tt-card>
<construction-consent-signature-pad
v-if="showSignaturePad"
:owner-id="selectedConsent.owner_id"
:owner-name="selectedConsent.owner_name"
@close="handleSignatureClose"
></construction-consent-signature-pad>
</div>
`,
methods: {
async searchOwners() {
this.loading = true;
try {
const response = await axios.post(
window.TT_CONFIG['BASE_PATH'] + '/ConstructionConsent/searchTablet',
{
name: this.searchName,
filter: this.searchFilter
}
);
if (response.data.success) {
this.results = response.data.results;
} else {
this.window.notify('error', response.data.message || 'Fehler bei der Suche');
this.results = [];
}
} catch (error) {
console.error('Error searching owners:', error);
this.window.notify('error', 'Ein Fehler ist bei der Suche aufgetreten');
this.results = [];
} finally {
this.loading = false;
}
},
openSignaturePad(consent) {
this.selectedConsent = consent;
this.showSignaturePad = true;
},
handleSignatureClose(success) {
this.showSignaturePad = false;
if (success) {
// Refresh the data after successful signing
this.searchOwners();
}
},
getStatusBadgeClass(status) {
switch (status) {
case 'open': return 'badge-primary';
case 'in_progress': return 'badge-warning';
case 'completed': return 'badge-success';
default: return 'badge-secondary';
}
},
getStatusText(status) {
switch (status) {
case 'open': return 'Offen';
case 'in_progress': return 'In Bearbeitung';
case 'completed': return 'Abgeschlossen';
default: return status;
}
},
getOwnerStatusBadgeClass(status) {
switch (status) {
case 'pending': return 'badge-warning';
case 'contacted': return 'badge-info';
case 'signed': return 'badge-success';
case 'returned': return 'badge-success';
default: return 'badge-secondary';
}
},
getOwnerStatusText(status) {
switch (status) {
case 'pending': return 'Ausstehend';
case 'contacted': return 'Kontaktiert';
case 'signed': return 'Unterschrieben';
case 'returned': return 'Zurückgegeben';
default: return status;
}
},
getOwnerResultBadgeClass(result) {
if (!result) return 'badge-primary';
switch (result) {
case 'accepted': return 'badge-success';
case 'rejected': return 'badge-danger';
case 'denied': return 'badge-danger';
default: return 'badge-secondary';
}
},
getOwnerResultText(result) {
if (!result) return 'Nicht signiert';
switch (result) {
case 'accepted': return 'Zugestimmt';
case 'rejected': return 'Abgelehnt';
default: return result;
}
}
},
mounted() {
// Initialize with empty search to show all results
this.searchOwners();
}
});

File diff suppressed because one or more lines are too long

View File

@@ -162,7 +162,7 @@ Vue.component('tt-table', {
<tt-input v-if="column.filter === 'search' && !disableFiltering" v-model="filters[column.key]" sm/>
<tt-icon-select v-else-if="column.filter === 'iconSelect' && !disableFiltering" :options="column.filterOptions" v-model="filters[column.key]" sm :multiple="column.filterOptions.length > 2 && this.ssr === true"/>
<tt-number-range v-else-if="column.filter === 'numberRange' && !disableFiltering" :returnText="!ssr" v-model="filters[column.key]" sm/>
<tt-select v-else-if="column.filter === 'select' && !disableFiltering" :options="[{text: 'Alle', value: undefined}, ...column.filterOptions]" v-model="filters[column.key]" sm multiple/>
<tt-select v-else-if="column.filter === 'select' && !disableFiltering" :options="[...column.filterOptions]" v-model="filters[column.key]" sm multiple/>
<tt-autocomplete v-else-if="column.filter === 'autocomplete' && !disableFiltering" :api-url="column.filterOptions" v-model="filters[column.key]" sm/>
<tt-date-picker v-else-if="(column.filter === 'date' || column.filter === 'datepicker') && !disableFiltering" v-model="filters[column.key]" sm/>
<!-- @formatter:on -->
@@ -559,6 +559,25 @@ Vue.component('tt-table', {
}
}, watch: {
data: {
handler: function () {
// we need to refresh the table if the prop data changes
this.loading = true;
this.rawRows = this.data;
this.pagination = {
page: Math.max(this.pagination.page, 1),
per_page: this.pagination?.per_page ? parseInt(this.pagination.per_page) : 10,
total_rows: this.rawRows.length || 0,
total_pages: this.rawRows.length / this.pagination?.per_page,
filtered_available: this.rawRows.length
}
this.loading = false;
this.isInitialised = true;
this.$nextTick(() => {
this.handleResponsiveColumns()
})
}
},
filters: {
handler: function () {
if (!this.isInitialised) return;