...
 
Commits (3)
  • schrieveslaach's avatar
    Upgrade to Nextcloud 15 · 9c1f7bc7
    schrieveslaach authored
    - Table view instead of cards and further UI improvement
    - Move to version 0.5.0
    - Move to Netxcloud 15
    9c1f7bc7
  • schrieveslaach's avatar
    Print labels for all members · 085ae2f3
    schrieveslaach authored
    - Move city path param to cities query param to render labels pdf of multiple cities
    - Move print modal into its own vue.js component and add multiple cities as property
    - Add footer containing print dialog for all cities
    085ae2f3
  • schrieveslaach's avatar
    Improve app info xml · 8d28de9f
    schrieveslaach authored
    8d28de9f
......@@ -5,14 +5,11 @@
<name>SPG Verein</name>
<summary>Access data of SPG-Verein in your Nextcloud instance</summary>
<description>
Access data of SPG-Verein in your Nextcloud instance. For example, your club members will be available in
your address book.
</description>
<description><![CDATA[Access data of SPG-Verein in your Nextcloud instance]]></description>
<licence>agpl</licence>
<author mail="info@schrieveslaach.de" homepage="https://schrieveslaach.de">Schrieveslaach</author>
<version>0.0.1</version>
<author mail="info@schrieveslaach.de" homepage="https://schrieveslaach.de">Marc Schreiber</author>
<version>0.5.0</version>
<namespace>SPGVerein</namespace>
<category>office</category>
......@@ -20,16 +17,13 @@
<category>organization</category>
<website>https://gitlab.com/schrieveslaach/nextcloud-spgverein-app</website>
<issues>https://gitlab.com/schrieveslaach/nextcloud-spgverein-app/issues</issues>
<bugs>https://gitlab.com/schrieveslaach/nextcloud-spgverein-app/issues</bugs>
<repository type="git">https://gitlab.com/schrieveslaach/nextcloud-spgverein-app.git</repository>
<screenshot>https://gitlab.com/schrieveslaach/nextcloud-spgverein-app/raw/master/assets/screenshot-01.png</screenshot>
<dependencies>
<nextcloud min-version="11" max-version="15"/>
<nextcloud min-version="14" max-version="15"/>
</dependencies>
<settings>
<admin>OCA\SPGVerein\Settings\Settings</admin>
<admin-section>OCA\SPGVerein\Settings\Section</admin-section>
</settings>
</info>
......@@ -11,6 +11,6 @@ $application->registerRoutes($this, array(
array('name' => 'club#listCities', 'url' => '/cities/{club}', 'verb' => 'GET'),
array('name' => 'file#downloadFile', 'url' => '/files/{club}/{memberId}/{filename}', 'verb' => 'GET'),
array('name' => 'label#formats', 'url' => '/labels/formats', 'verb' => 'GET'),
array('name' => 'label#downloadLabels', 'url' => '/labels/{club}/{city}', 'verb' => 'GET')
array('name' => 'label#downloadLabels', 'url' => '/labels/{club}', 'verb' => 'GET')
)
));
......@@ -7,6 +7,12 @@
grid-template-columns: 1fr 1fr;
}
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
.city-header h2 {
min-width: 250px;
}
}
.city-header a {
justify-self: end;
}
......@@ -40,40 +46,98 @@
.text-box-label {
display: block;
}
.text-box-input {
width: 100%;
}
.member {
width: 90%;
min-height: 7rem;
background: #fff;
border-radius: 2px;
margin: 0.1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
transition: all 0.3s cubic-bezier(.25, .8, .25, 1);
padding: 0.3rem;
table {
width: 100%;
border-collapse: collapse;
}
.member > .docs {
padding-top: 0.3rem;
padding-bottom: 0.3rem;
tr:nth-of-type(even) {
background: var(--color-background-darker);
}
.member > .docs > a {
white-space: nowrap;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
display: block
th {
background: var(--color-primary);
color: var(--color-primary-text);
font-weight: bold;
}
.member > .docs > a + a {
padding-left: 0.5rem;
td, th {
padding: 6px;
border: 1px solid var(--color-background-darker);
text-align: left;
}
@media only screen and (max-width: 760px), (min-device-width: 768px) and (max-device-width: 1024px) {
/* Force table to not be like tables anymore */
table, thead, tbody, th, td, tr {
display: block;
}
/* Hide table headers (but not display: none;, for accessibility) */
thead tr {
position: absolute;
top: -9999px;
left: -9999px;
}
tr {
border: 1px solid var(--color-background-darker);
}
td {
/* Behave like a "row" */
border: none;
border-bottom: 1px solid var(--color-background-darker);
position: relative;
padding-left: 30%;
}
td:before {
/* Now like a table header */
position: absolute;
/* Top/left values mimic padding */
top: 6px;
left: 6px;
width: 25%;
padding-right: 10px;
white-space: nowrap;
}
td:nth-of-type(1):before {
content: "Name";
}
td:nth-of-type(2):before {
content: "Straße";
}
td:nth-of-type(3):before {
content: "Postleitzahl";
}
td:nth-of-type(4):before {
content: "Ort";
}
td:nth-of-type(5):before {
content: "Anhänge";
}
}
.member:hover {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.22);
.attachment-link {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.club-selection {
......@@ -91,14 +155,14 @@
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, .3);
background-color: var(--color-box-shadow);
display: flex;
justify-content: center;
align-items: center;
}
.modal {
background: #ffffff;
background: var(--color-main-background);
box-shadow: 2px 2px 20px 1px;
overflow-x: auto;
display: flex;
......@@ -112,9 +176,9 @@
}
.modal-header {
border-bottom: 1px solid #eeeeee;
border-bottom: 1px solid var(--color-border-dark);
font-size: large;
color: #0082c9;
color: var(--color-primary);
justify-content: space-between;
text-align: center;
line-height: 3rem;
......@@ -130,6 +194,20 @@
font-size: 20px;
cursor: pointer;
font-weight: bold;
color: #0082c9;
color: var(--color-primary);
background: transparent;
}
footer {
background: var(--color-main-background);
position: fixed;
bottom: 0;
height: 40px;
z-index: 1000;
left: 0;
right: 0;
}
footer > a {
float: right;
}
\ No newline at end of file
<template>
<div id="app-content-wrapper">
<div style="width: 100%">
<section class="club-selection">
<div style="width: 100%; margin-bottom: 75px">
<section class="club-selection" v-if="clubs.length > 1">
<h3>
Bestand
</h3>
......@@ -14,11 +13,21 @@
<members v-bind:members="members" v-bind:cities="cities" :club="selectedClub"></members>
</div>
<footer>
<a class="button" @click="showPrintAllMembers()">
<font-awesome-icon icon="print"/>
Etiketten aller Mitgliedre drucken
</a>
</footer>
<labels-modal :club="selectedClub" :cities="cities" v-if="printAllLabels" @close="closePrintAllMembers()" />
</div>
</template>
<script>
import Members from './members.vue';
import LabelsModal from './labels-modal.vue';
export default {
data() {
......@@ -26,24 +35,23 @@
clubs: [],
cities: [],
members: [],
selectedClub: ''
selectedClub: '',
printAllLabels: false
};
},
components: {
'members': Members
Members,
LabelsModal
},
methods: {
print(e) {
const members = this.members.filter(member => {
if (!e.city) {
return true;
}
return member.city === e.city;
});
generatePdf(members);
showPrintAllMembers() {
this.printAllLabels = true;
},
closePrintAllMembers() {
this.printAllLabels = false;
},
fetchMembers() {
fetch(OC.generateUrl(`/apps/spgverein/members/${this.selectedClub}`,))
......
<template>
<modal @close="close()">
<template slot="header">
Etiketten<span v-if="cities.length === 1">{{ cities[0] }}</span>
</template>
<template slot="body">
<div class="label-parameters">
<div>
<label for="group-by-checkbox" style="word-wrap:break-word">
<input id="group-by-checkbox" type="checkbox" @input="groupMembersInput"
:checked="groupMembers" style="vertical-align: middle;">
Gruppieren über Mitgliedsnummer
</label>
</div>
<div>
<label for="addressline-input" class="text-box-label">
Adresszeile
</label>
<input placeholder="Adresszeile" id="addressline-input" @input="addressLineInput"
:value="addressLine" class="text-box-input">
</div>
<div>
<div style="width: 49%; float: left;">
<label for="label-format-select" class="text-box-label">
Etikettenformat
</label>
<select id="label-format-select" v-model="selectedLabelFormat" class="text-box-input">
<option v-for="(item, index) in labelFormats" :value="item.id" :selected="index === 0">
{{item.id}} ({{item.size}}, {{item.rows}}&#215;{{item.columns}})
</option>
</select>
</div>
<div style="width: 49%; float: right;">
<label for="label-offset" class="text-box-label">
Anfang leere Etiketten
</label>
<input type="number" min="0" v-model="labelOffset" :max="maxLabelOffset" step="1"
id="label-offset" class="text-box-input">
</div>
</div>
</div>
<object :data="labelsUrl" type="application/pdf" class="labels">
<embed :src="labelsUrl" type="application/pdf"/>
</object>
<p style="position: absolute; top: 0; right: 0;">
<a class="button" :href="labelsUrl" target="_blank" style="float: right">
<font-awesome-icon icon="download"/>
Download
</a>
</p>
</template>
</modal>
</template>
<script>
import debounce from 'debounce';
export default {
data() {
return {
addressLine: '',
groupMembers: false,
labelFormatData: {},
selectedLabelFormat: null,
labelOffset: 0
}
},
mounted() {
if (localStorage.addressLine) {
this.addressLine = localStorage.addressLine;
}
if (localStorage.groupMembers) {
this.groupMembers = localStorage.groupMembers;
}
fetch(OC.generateUrl(`/apps/spgverein/labels/formats`))
.then(response => response.json())
.then(formats => {
this.labelFormatData = formats;
if (localStorage.selectedLabelFormat != null) {
this.selectedLabelFormat = localStorage.selectedLabelFormat;
}
});
},
computed: {
maxLabelOffset() {
if (this.labelFormatData == null || this.selectedLabelFormat == null) {
return 0;
}
const format = this.labelFormatData[this.selectedLabelFormat];
return format.columns * format.rows - 1;
},
labelFormats() {
return Object.keys(this.labelFormatData)
.map(id => ({id, ...this.labelFormatData[id]}))
},
labelsUrl() {
let params = {};
if (this.addressLine.length > 0) {
params.addressLine = this.addressLine;
}
if (this.groupMembers) {
params.groupMembers = this.groupMembers;
}
if (this.selectedLabelFormat != null) {
params.format = this.selectedLabelFormat;
}
if (this.labelOffset > 0) {
params.offset = this.labelOffset;
}
if (this.cities.length > 0) {
params.cities = this.cities
.map(v => encodeURIComponent(v))
.join(',');
}
const query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return OC.generateUrl(`/apps/spgverein/labels/${this.club}?${query}`);
}
},
props: {
cities: {
type: Array
},
club: {
type: String
}
},
methods: {
close() {
this.$emit('close')
},
addressLineInput: debounce(function (e) {
this.addressLine = e.target.value;
}, 750),
groupMembersInput: debounce(function (e) {
const checked = e.target.checked;
this.groupMembers = checked != null && checked;
}, 750)
},
watch: {
addressLine(newAddressLine) {
localStorage.addressLine = newAddressLine;
},
groupMembers(newGroupMembers) {
localStorage.groupMembers = newGroupMembers;
},
selectedLabelFormat(newSelectedLabelFormat) {
localStorage.selectedLabelFormat = newSelectedLabelFormat;
}
}
}
</script>
\ No newline at end of file
<template>
<div :id="member.id" class="member">
<p v-for="name in member.fullnames">
<tr :id="member.id">
<td v-for="name in member.fullnames">
{{ name }}
</p>
<p>{{ member.street }}</p>
<p>{{ member.zipcode }} {{ member.city }}</p>
</td>
<td>{{ member.street }}</td>
<td>{{ member.zipcode }}</td>
<td>{{ member.city }}</td>
<br v-if="Object.keys(member.files).length > 0">
<div class="docs" v-if="Object.keys(member.files).length > 0">
<a class="button" :href="getFileUrl(file)" v-for="file in member.files">
<font-awesome-icon icon="file"/> {{ file }}
</a>
</div>
</div>
<td>
<template v-if="Object.keys(member.files).length > 0">
<a class="attachment-link" :href="getFileUrl(file)" v-for="file in member.files">
<font-awesome-icon icon="file"/>
{{ file }}
</a>
</template>
<template v-else>
<span>&nbsp;</span>
</template>
</td>
</tr>
</template>
<script>
......
......@@ -8,148 +8,52 @@
</a>
</div>
<div class="members">
<member :club="club" v-bind:member="member" v-for="member in getMembersOf(city)"></member>
</div>
<table>
<colgroup>
<col style="width:25%">
<col style="width:20%">
<col style="width:5%">
<col style="width:20%">
<col style="width:30%">
</colgroup>
<thead>
<tr>
<th>Name</th>
<th>Straße</th>
<th>Postleitzahl</th>
<th>Ort</th>
<th>Anhänge</th>
</tr>
</thead>
<tbody>
<member :club="club" v-bind:member="member" v-for="member in getMembersOf(city)" :key="member.id"/>
</tbody>
</table>
</section>
<modal v-if="showModal" v-on:close="closeLabelsDialog()">
<template slot="header">
Etiketten – {{ selectedCityForLabels }}
</template>
<template slot="body">
<div class="label-parameters">
<div>
<label for="group-by-checkbox" style="word-wrap:break-word">
<input id="group-by-checkbox" type="checkbox" @input="groupMembersInput"
:checked="groupMembers" style="vertical-align: middle;">
Gruppieren über Mitgliedsnummer
</label>
</div>
<div>
<label for="addressline-input" class="text-box-label">
Adresszeile
</label>
<input placeholder="Adresszeile" id="addressline-input" @input="addressLineInput"
:value="addressLine" class="text-box-input">
</div>
<div>
<div style="width: 49%; float: left;">
<label for="label-format-select" class="text-box-label">
Etikettenformat
</label>
<select id="label-format-select" v-model="selectedLabelFormat" class="text-box-input">
<option v-for="(item, index) in labelFormats" :value="item.id" :selected="index === 0">
{{item.id}} ({{item.size}}, {{item.rows}}&#215;{{item.columns}})
</option>
</select>
</div>
<div style="width: 49%; float: right;">
<label for="label-offset" class="text-box-label">
Anfang leere Etiketten
</label>
<input type="number" min="0" v-model="labelOffset" :max="maxLabelOffset" step="1"
id="label-offset" class="text-box-input">
</div>
</div>
</div>
<object :data="labelsUrl" type="application/pdf" class="labels">
<embed :src="labelsUrl" type="application/pdf"/>
</object>
<p style="position: absolute; top: 0; right: 0;">
<a class="button" :href="labelsUrl" target="_blank" style="float: right">
<font-awesome-icon icon="download"/>
Download
</a>
</p>
</template>
</modal>
<labels-modal :club="club" :cities="[selectedCityForLabels]" v-if="showModal" @close="closeLabelsDialog" />
</div>
</template>
<script>
import Member from './member.vue';
import debounce from 'debounce';
import LabelsModal from './labels-modal.vue';
export default {
data() {
return {
selectedCityForLabels: null,
addressLine: '',
groupMembers: false,
labelFormatData: {},
selectedLabelFormat: null,
labelOffset: 0
}
},
mounted() {
if (localStorage.addressLine) {
this.addressLine = localStorage.addressLine;
}
if (localStorage.groupMembers) {
this.groupMembers = localStorage.groupMembers;
}
fetch(OC.generateUrl(`/apps/spgverein/labels/formats`))
.then(response => response.json())
.then(formats => {
this.labelFormatData = formats;
if (localStorage.selectedLabelFormat != null) {
this.selectedLabelFormat = localStorage.selectedLabelFormat;
}
});
},
components: {
member: Member
Member,
LabelsModal
},
computed: {
maxLabelOffset() {
if (this.labelFormatData == null || this.selectedLabelFormat == null) {
return 0;
}
const format = this.labelFormatData[this.selectedLabelFormat];
return format.columns * format.rows - 1;
},
labelFormats() {
return Object.keys(this.labelFormatData)
.map(id => ({id, ...this.labelFormatData[id]}))
},
showModal() {
return this.selectedCityForLabels != null;
},
labelsUrl() {
let params = {};
if (this.addressLine.length > 0) {
params.addressLine = this.addressLine;
}
if (this.groupMembers) {
params.groupMembers = this.groupMembers;
}
if (this.selectedLabelFormat != null) {
params.format = this.selectedLabelFormat;
}
if (this.labelOffset > 0) {
params.offset = this.labelOffset;
}
const query = Object.keys(params)
.map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k]))
.join('&');
return OC.generateUrl(`/apps/spgverein/labels/${this.club}/${this.selectedCityForLabels}?${query}`);
}
},
......@@ -175,16 +79,7 @@
},
closeLabelsDialog() {
this.selectedCityForLabels = null;
},
addressLineInput: debounce(function (e) {
this.addressLine = e.target.value;
}, 750),
groupMembersInput: debounce(function (e) {
const checked = e.target.checked;
this.groupMembers = checked != null && checked;
}, 750)
}
},
watch: {
......
{
"name": "spgverein",
"version": "0.0.1",
"version": "0.5.0",
"scripts": {
"build": "webpack --config webpack.config.js"
},
......
......@@ -40,33 +40,14 @@ class LabelController extends Controller
* @NoAdminRequired
* @NoCSRFRequired
*/
public function downloadLabels(string $club, string $city): StreamResponse
public function downloadLabels(string $club): StreamResponse
{
$members = $this->club->getAllMembers($club);
$members = array_filter($members, function ($member) use ($city) {
return $member->getCity() === $city;
});
usort($members, function ($a, $b) {
$m1 = array();
$m2 = array();
preg_match('/(.*)\s+((\d+)\s*([a-z])?)/', $a->getStreet(), $m1);
preg_match('/(.*)\s+((\d+)\s*([a-z])?)/', $b->getStreet(), $m2);
$cmp = strcmp($m1[1], $m2[1]);
if ($cmp === 0) {
$n1 = intval($m1[3]);
$n2 = intval($m2[3]);
if ($n1 < $n2)
$cmp = -1;
else if ($n1 > $n2)
$cmp = 1;
else
$cmp = 0;
}
return $cmp;
$cities = str_getcsv(urldecode($this->request->getParam("cities", "")));
error_log("cities " . implode(" ", $cities));
$members = array_filter($members, function ($member) use ($cities) {
return in_array($member->getCity(), $cities);
});
$format = trim(urldecode($this->request->getParam("format", "L7163")));
......
......@@ -29,20 +29,7 @@ class PageController extends Controller
{
$response = new TemplateResponse('spgverein', 'index');
$csp = new ContentSecurityPolicy();
$csp->allowInlineScript(true)
->allowInlineStyle(true)
->allowEvalScript(true);
$csp->addAllowedScriptDomain('*')
->addAllowedStyleDomain('*')
->addAllowedFontDomain('*')
->addAllowedImageDomain('*')
->addAllowedConnectDomain('*')
->addAllowedMediaDomain('*')
->addAllowedObjectDomain('*')
->addAllowedFrameDomain('*')
->addAllowedChildSrcDomain('*');
$csp->addAllowedObjectDomain('*');
$response->setContentSecurityPolicy($csp);
return $response;
}
......
......@@ -49,6 +49,33 @@ class Club
$previous = $buffer;
}
usort($members, function ($a, $b) {
$cmp = strcmp($a->getCity(), $b->getCity());
if ($cmp !== 0) {
return $cmp;
}
$m1 = array();
$m2 = array();
preg_match('/(.*)\s+((\d+)\s*([a-z])?)/', $a->getStreet(), $m1);
preg_match('/(.*)\s+((\d+)\s*([a-z])?)/', $b->getStreet(), $m2);
$cmp = strcmp($m1[1], $m2[1]);
if ($cmp === 0) {
$n1 = intval($m1[3]);
$n2 = intval($m2[3]);
if ($n1 < $n2)
$cmp = -1;
else if ($n1 > $n2)
$cmp = 1;
else
$cmp = 0;
}
return $cmp;
});
return $members;
}
......
<?php
/** @var $l \OCP\IL10N */
/** @var $_ array */
script('spgverein', 'admin'); // adds a JavaScript file
?>
<div id="survey_client" class="section">
<h2><?php p($l->t('spgverein')); ?></h2>
<p>
<?php p($l->t('Only administrators are allowed to click the red button')); ?>
</p>
<button><?php p($l->t('Click red button')); ?></button>
<p>
<input id="your_app_magic" name="your_app_magic"
type="checkbox" class="checkbox" value="1" <?php if ($_['is_enabled']): ?> checked="checked"<?php endif; ?> />
<label for="your_app_magic"><?php p($l->t('Do some magic')); ?></label>
</p>
<h3><?php p($l->t('Things to define')); ?></h3>
<?php
foreach ($_['categories'] as $category => $data) {
?>
<p>
<input id="your_app_<?php p($category); ?>" name="your_app_<?php p($category); ?>"
type="checkbox" class="checkbox your_app_category" value="1" <?php if ($data['enabled']): ?> checked="checked"<?php endif; ?> />
<label for="your_app_<?php p($category); ?>"><?php print_unescaped($data['displayName']); ?></label>
</p>
<?php
}
?>
<?php if (!empty($_['last_report'])): ?>
<h3><?php p($l->t('Last report')); ?></h3>
<p><textarea title="<?php p($l->t('Last report')); ?>" class="last_report" readonly="readonly"><?php p($_['last_report']); ?></textarea></p>
<em class="last_sent"><?php p($l->t('Sent on: %s', [$_['last_sent']])); ?></em>
<?php endif; ?>
</div>
\ No newline at end of file
......@@ -4,11 +4,8 @@ style('spgverein', 'style');
?>
<div id="app">
<div id="app-content">
<div id="app-content-wrapper">
</div>
<div id="app-content">
<div id="app-content-wrapper">
</div>
</div>
......