-
кв.${apt.title}
@@ -350,9 +369,6 @@ const Territory_card = {
async map({ homestead_id = Territory_card.id }) {
let data = await this.loadAPI({ url: `${CONFIG.api}homestead/${homestead_id}` });
- console.log(data);
-
-
let lat = data.geo?.lat ?? data.points?.[0]?.[0]?.[0]?.lat ?? 49.5629016;
let lng = data.geo?.lng ?? data.points?.[0]?.[0]?.[0]?.lng ?? 25.6145625;
let zoom = 15;
@@ -483,10 +499,116 @@ const Territory_card = {
Territory_card.getHomestead.markers[element.id] = marker; // сохраним ссылку на маркер
}
+
+ if((USER.possibilities.can_joint_territory && data.history.sheep_id == USER.id) || USER.mode == 2){
+ this.joint.setHTML(homestead_id);
+ }
},
+
+ joint: {
+ async setHTML(homestead_id){
+ let lest = await this.getJoint(homestead_id);
+
+ let block_info = document.getElementById('page-card-info');
+
+ block_info.style.display = "flex";
+ block_info.innerHTML = `
+
Надати спільний доступ:
+
+ ${Sheeps.sheeps_list.list.map(p => {
+ const isSelected = lest.some(item => item.sheep_id === p.id);
+ if(USER.id === Number(p.id) && USER.mode != 2) return
+ return `
+ ${p.name}
+
`;
+ }).join('')}
+
+ `;
+ },
+
+ setJoint(homestead_id){
+ const select = document.getElementById(`joint-${homestead_id}`);
+ if (!select) return;
+ console.log(select.getClick);
+
+ if(select.getClick.state == "add"){
+ this.addSheep(homestead_id, select.getClick.value);
+ } else if(select.getClick.state == "delete"){
+ this.delSheep(homestead_id, select.getClick.value);
+ }
+ },
+
+ async getJoint(homestead_id){
+ let uuid = localStorage.getItem("uuid");
+
+ return await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
+ method: 'GET',
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": uuid
+ }
+ }).then((response) => response.json());
+ },
+
+ async addSheep(homestead_id, sheep_id){
+ const uuid = localStorage.getItem('uuid');
+
+ if (!homestead_id) {
+ console.warn("Невірні дані для наданя доступу.");
+ return;
+ }
+
+ try {
+ const response = await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
+ method: 'POST',
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": uuid
+ },
+ body: JSON.stringify({"sheep_id": sheep_id})
+ });
+
+ if (!response.ok) throw new Error("Failed to assign");
+
+ Notifier.success('Віснику успішно надано доступ.');
+ } catch (err) {
+ console.error('❌ Error:', err);
+ Notifier.error('Помилка надання доступу.');
+ }
+ },
+ async delSheep(homestead_id, sheep_id){
+ const uuid = localStorage.getItem('uuid');
+
+ if (!homestead_id) {
+ console.warn("Невірні дані для відкликання доступу.");
+ return;
+ }
+
+ try {
+ const response = await fetch(`${CONFIG.api}homestead/joint/${homestead_id}`, {
+ method: 'DELETE',
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": uuid
+ },
+ body: JSON.stringify({"sheep_id": sheep_id})
+ });
+
+ if (!response.ok) throw new Error("Failed to assign");
+
+ Notifier.success('Доступ успішно відкликанно.');
+ } catch (err) {
+ console.error('❌ Error:', err);
+ Notifier.error('Помилка при відкликанні доступу.');
+ }
+ }
+ }
},
- async reload(){
+ async reload() {
Territory_card.getEntrances({ update: true });
},
@@ -547,5 +669,39 @@ const Territory_card = {
}
this.close();
}
+ },
+
+ info(data) {
+ let block_info = document.getElementById('page-card-info');
+
+ block_info.style.display = "flex";
+ block_info.innerHTML = `
+
${data[0].address.title} ${data[0].address.number}
+
+
Терміни опрацювання:
+ `
+
+ for (let index = 0; index < data.length; index++) {
+ const element = data[index];
+
+ const canManage = USER.mode === 2 || (USER.mode === 1 && USER.possibilities.can_manager_territory);
+ const isMy = ((element.history.name === "Групова" && element.history.group_id == USER.group_id) || element.history.name === USER.name);
+
+ let date_start = element.history.date.start;
+ let date_end = date_start + (1000 * 2629743 * 4);
+ let red = () => {
+ if(Date.now() > date_end) return `color: #ec2d2d;`
+ return
+ }
+
+ if (element.working && (isMy || canManage)) {
+ block_info.innerHTML += `
+
+
${element.title}
+ ${formattedDate(date_start)} — ${formattedDate(date_end)}
+
+ `;
+ }
+ }
}
}
\ No newline at end of file
diff --git a/web/lib/pages/territory/card/style.css b/web/lib/pages/territory/card/style.css
index ddacce2..bb1c115 100644
--- a/web/lib/pages/territory/card/style.css
+++ b/web/lib/pages/territory/card/style.css
@@ -88,6 +88,48 @@
}
+#page-card-info {
+ padding: 10px;
+ margin: 0px 0 10px 0;
+ background: var(--ColorThemes1);
+ color: var(--ColorThemes3);
+ border: 1px solid var(--ColorThemes2);
+ box-shadow: var(--shadow-l1);
+ border-radius: var(--border-radius);
+ /* overflow: auto; */
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
+ gap: 10px;
+}
+
+#page-card-info>a {
+ font-size: var(--FontSize5);
+ font-weight: 500;
+}
+
+#page-card-info>h2 {
+ font-size: var(--FontSize4);
+}
+
+#page-card-info>div {
+ display: flex;
+ align-items: flex-end;
+ gap: 10px;
+}
+
+#page-card-info>div>h3 {
+ font-size: var(--FontSize3);
+ font-weight: 500;
+ opacity: 0.9;
+}
+
+#page-card-info>div>h4 {
+ font-size: var(--FontSize2);
+ font-weight: 400;
+ opacity: 0.8;
+}
+
#card-new-date {
display: flex;
width: 100%;
@@ -149,4 +191,221 @@
font-weight: 400;
-webkit-appearance: none;
-moz-appearance: none;
+}
+
+.page-card *[disabled] {
+ opacity: 0.5;
+}
+
+.page-card #list {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 15px;
+}
+
+.page-card details {
+ color: var(--ColorThemes3);
+ width: 100%;
+ min-width: 320px;
+ background: var(--ColorThemes1);
+ border-radius: var(--border-radius);
+ overflow: hidden;
+ border: 1px solid var(--ColorThemes2);
+ box-shadow: var(--shadow-l1);
+}
+
+@media (min-width: 900px) {
+ .page-card #list {
+ align-items: flex-start;
+ justify-content: space-between;
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .page-card details {
+ width: calc(50% - 10px);
+ }
+}
+
+.page-card details[disabled] .page-card details summary,
+.page-card details.disabled .page-card details summary {
+ pointer-events: none;
+ user-select: none;
+}
+
+.page-card details .page-card details summary::-webkit-.page-card details-marker,
+.page-card details .page-card details summary::marker {
+ display: none;
+ content: "";
+}
+
+.page-card details summary {
+ cursor: pointer;
+ height: 45px;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.page-card details summary p {
+ padding: 0 10px;
+ font-size: var(--FontSize3);
+ font-weight: 500;
+}
+
+.page-card details summary svg {
+ width: 25px;
+ height: 25px;
+ padding: 0 10px;
+ fill: currentColor;
+}
+
+.page-card .apartments_list {
+ padding: 10px;
+ gap: 15px;
+ display: flex;
+ flex-direction: column;
+}
+
+.page-card .apartments_list>p {
+ font-size: var(--FontSize5);
+ text-align: center;
+ color: var(--ColorThemes3);
+ padding: 10px;
+}
+
+.page-card .card_info {
+ display: flex;
+ font-size: var(--FontSize3);
+ border-radius: 8px;
+ flex-direction: column;
+ align-items: stretch;
+ border: 1px solid var(--ColorThemes3);
+ background-color: var(--ColorThemes2);
+ gap: 6px;
+ padding: 6px;
+}
+
+.card_info_homestead {
+ border-radius: 6px !important;
+ width: calc(100% - 15px);
+ margin: 0;
+}
+
+.page-card .card_info>span {
+ font-size: var(--FontSize3);
+ font-weight: 500;
+ position: relative;
+}
+
+.page-card .card_info>hr {
+ height: 1px;
+ background-color: currentColor;
+ opacity: 0.1;
+ margin-bottom: 4px;
+}
+
+.page-card .card_info>.info {
+ display: flex;
+ font-size: var(--FontSize3);
+ align-items: center;
+ justify-content: space-between;
+ border-radius: 8px;
+ gap: 5px;
+}
+
+.page-card .card_info>.info>select {
+ color: #3d3d3d;
+ border-radius: 6px;
+ border: 1px solid #eaebef;
+ background-color: var(--ColorThemes3);
+ min-width: 110px;
+ width: 50%;
+ padding: 4px;
+ height: 30px;
+}
+
+.page-card .card_info>.info>input,
+.page-card .card_info>.info>button {
+ font-size: var(--FontSize2);
+ font-weight: 400;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ border-radius: 6px;
+ background-color: var(--ColorThemes0);
+ border: 1px solid var(--ColorThemes1);
+ color: var(--ColorThemes3);
+ width: 50%;
+ min-width: 70px;
+ padding: 0 4px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ cursor: pointer;
+}
+
+.page-card .card_info>.info>button>svg {
+ width: 15px;
+ height: 15px;
+ fill: var(--ColorThemes3);
+}
+
+.page-card .card_info>textarea {
+ border-radius: 6px;
+ font-size: var(--FontSize3);
+ background-color: var(--ColorThemes0);
+ border: 0;
+ color: var(--ColorThemes3);
+ width: calc(100% - 10px);
+ min-width: 70px;
+ padding: 5px;
+ min-height: 40px;
+ appearance: none;
+ resize: vertical;
+ -webkit-appearance: none;
+}
+
+.page-card .card_info>textarea::placeholder {
+ color: var(--ColorThemes3);
+ opacity: 0.5;
+}
+
+.page-card .card_info>textarea::-webkit-input-placeholder {
+ color: var(--ColorThemes3);
+ opacity: 0.5;
+}
+
+.page-card #map_card {
+ display: none;
+ width: 100%;
+ height: calc(100vh - 100px);
+ background: var(--ColorThemes1);
+ color: var(--ColorThemes3);
+ border: 1px solid var(--ColorThemes2);
+ box-shadow: var(--shadow-l1);
+ border-radius: var(--border-radius);
+ margin-bottom: 15px;
+}
+
+
+smart-select {
+ --smart-select-option-color: var(--ColorThemes3);
+ --smart-select-border-color: var(--ColorThemes2);
+ --smart-select-background: var(--ColorThemes0);
+ --smart-select-color: var(--PrimaryColor);
+ --smart-select-chip-fill: var(--PrimaryColorText);
+ --smart-select-chip-background: var(--PrimaryColor);
+ --smart-select-chip-color: var(--ColorThemes3);
+ --smart-select-search-color: var(--ColorThemes3);
+ --smart-select-hover-background: var(--ColorThemes2);
+ --smart-select-hover-color: var(--ColorThemes3);
+ --smart-select-selected-background: var(--PrimaryColor);
+ --smart-select-selected-color: var(--PrimaryColorText);
+ --smart-select-font-size-1: var(--FontSize1);
+ --smart-select-font-size-2: var(--FontSize3);
+ --smart-select-border-radius-1: 8px;
+ --smart-select-border-radius-2: 4px;
}
\ No newline at end of file
diff --git a/web/lib/pages/territory/editor/index.html b/web/lib/pages/territory/editor/index.html
index 3317989..c54f48f 100644
--- a/web/lib/pages/territory/editor/index.html
+++ b/web/lib/pages/territory/editor/index.html
@@ -13,12 +13,20 @@
name="address"
required
value=""
+ onchange="Territory_editor.info.title=this.value"
/>
@@ -29,6 +37,7 @@
name="settlement"
required
value="Тернопіль"
+ onchange="Territory_editor.info.settlement=this.value"
/>
@@ -66,14 +75,18 @@
diff --git a/web/lib/pages/territory/list/script.js b/web/lib/pages/territory/list/script.js
index e05fc7a..052ec4e 100644
--- a/web/lib/pages/territory/list/script.js
+++ b/web/lib/pages/territory/list/script.js
@@ -40,10 +40,11 @@ const Territory_list = {
},
house: {
+ url: null,
list: [],
loadAPI: async function (url) {
const uuid = localStorage.getItem("uuid");
- const response = await fetch(url, {
+ const response = await fetch(url ?? Territory_list.house.url, {
method: 'GET',
headers: {
"Content-Type": "application/json",
@@ -60,6 +61,7 @@ const Territory_list = {
const territory_list_filter = Number(localStorage.getItem("territory_list_filter") ?? 0);
const url = `${CONFIG.api}houses/list${territory_entrances ? '/entrances' : ''}`;
+ Territory_list.house.url = url;
let list = this.list.length > 0 ? this.list : await this.loadAPI(url);
const isEnd = territory_list_filter === "2";
@@ -94,11 +96,13 @@ const Territory_list = {
const person = working
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}`
: ``;
+ const overdue = working && (element.history.date.start + (1000 * 2629743 * 4)) <= Date.now();
card.image = `${CONFIG.web}cards/house/T${element.house.id}.webp`;
card.address = `${element.house.title} ${element.house.number} (${element.title})`;
card.link = `/territory/manager/house/${element.house.id}`;
card.sheep = person;
+ card.overdue = overdue;
block.appendChild(card);
} else {
const qty = element.entrance.quantity;
@@ -154,8 +158,8 @@ const Territory_list = {
}
});
- this.list = await response.json();
- return this.list;
+ Territory_list.homestead.list = await response.json();
+ return Territory_list.homestead.list;
},
setHTML: async function () {
const block = document.getElementById('list-homestead');
@@ -186,12 +190,15 @@ const Territory_list = {
const person = working
? `${element.history.name === 'Групова' ? 'Група ' + element.history.group_id : element.history.name}`
: ``;
+
+ const overdue = working && (element.history.date.start + (1000 * 2629743 * 4)) <= Date.now();
const card = document.createElement('app-territory-card');
card.image = `${CONFIG.web}cards/homestead/H${element.id}.webp`;
card.address = `${element.title} ${element.number}`;
card.link = `/territory/manager/homestead/${element.id}`;
card.sheep = person;
+ card.overdue = overdue;
block.appendChild(card);
}
}
diff --git a/web/lib/pages/territory/manager/script.js b/web/lib/pages/territory/manager/script.js
index a68da30..e68e709 100644
--- a/web/lib/pages/territory/manager/script.js
+++ b/web/lib/pages/territory/manager/script.js
@@ -358,6 +358,11 @@ const Territory_Manager = {
Territory_Manager.mess.close();
Territory_Manager.entrances.list = [];
await Territory_Manager.entrances.setHTML(type, id);
+
+ Territory_list.house.list = [];
+ Territory_list.homestead.list = [];
+ Territory_list.house.loadAPI();
+ Territory_list.homestead.loadAPI();
} catch (err) {
console.error('❌ Error:', err);
Notifier.error('Помилка призначення території');
@@ -389,6 +394,11 @@ const Territory_Manager = {
Territory_Manager.entrances.list = [];
await Territory_Manager.entrances.setHTML(type, id);
+ Territory_list.house.list = [];
+ Territory_list.homestead.list = [];
+ Territory_list.house.loadAPI();
+ Territory_list.homestead.loadAPI();
+
Notifier.success('Територія забрана успішно');
} catch (error) {
console.error("❌ Помилка зняття призначення:", error);
diff --git a/web/lib/pages/territory/map/script.js b/web/lib/pages/territory/map/script.js
index a007f0e..7664d89 100644
--- a/web/lib/pages/territory/map/script.js
+++ b/web/lib/pages/territory/map/script.js
@@ -1,7 +1,7 @@
-let map_all;
+let map_all, free_entrance, free_homesteads;
const Territory_Map = {
- init: async () => {
+ async init() {
let html = await fetch('/lib/pages/territory/map/index.html').then((response) => response.text());
app.innerHTML = html;
@@ -9,7 +9,7 @@ const Territory_Map = {
Territory_Map.info.setHTML();
},
info: {
- loadAPI: async (url) => {
+ async loadAPI(url) {
const uuid = localStorage.getItem("uuid");
const response = await fetch(url, {
@@ -23,22 +23,22 @@ const Territory_Map = {
return await response.json();
},
- setHTML: async () => {
+ async setHTML() {
const houses = await Territory_Map.info.loadAPI(`${CONFIG.api}houses/list`);
const homestead = await Territory_Map.info.loadAPI(`${CONFIG.api}homestead/list`);
- Territory_Map.map.added({ type: "houses", data: houses });
+ Territory_Map.map.added({ type: "house", data: houses });
Territory_Map.map.added({ type: "homestead", data: homestead });
}
},
map: {
polygons: [],
- init: () => {
+ init() {
if (map_all && map_all.remove) map_all.remove();
-
const mapElement = document.getElementById('map');
if (!mapElement) return;
+ let firstLocate = true;
let googleHybrid = L.tileLayer('http://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', {
maxZoom: 20,
@@ -55,6 +55,9 @@ const Territory_Map = {
tms: true
});
+ free_entrance = new L.FeatureGroup();
+ free_homesteads = new L.FeatureGroup();
+
map_all = L.map(mapElement, {
renderer: L.canvas(),
center: [49.5629016, 25.6145625],
@@ -63,22 +66,72 @@ const Territory_Map = {
layers: [
googleHybrid,
osm,
- mytile
+ mytile,
+ free_entrance,
+ free_homesteads
]
});
+ map_all.locate({
+ setView: true, // 🔥 сразу центрирует карту
+ maxZoom: 16
+ });
+
let baseMaps = {
"Google Hybrid": googleHybrid,
"OpenStreetMap": osm,
"Sheep Service Map": mytile,
};
- let layerControl = L.control.layers(baseMaps, [], { position: 'bottomright' }).addTo(map_all);
+ let baseLayer = {
+ "Вільні під'їзди": free_entrance,
+ "Вільні райони": free_homesteads
+ };
+
+
+ L.control.layers(baseMaps, baseLayer, { position: 'bottomright' }).addTo(map_all);
map_all.pm.setLang("ua");
+
+ map_all.on('zoomend', () => {
+ const z = map_all.getZoom();
+
+ if (z <= 15) {
+ map_all.removeLayer(free_homesteads);
+ } else {
+ map_all.addLayer(free_homesteads);
+ }
+
+ if (z <= 16) {
+ map_all.removeLayer(free_entrance);
+ } else {
+ map_all.addLayer(free_entrance);
+ }
+ });
+
+ // слежение в реальном времени
+ map_all.locate({ setView: false, watch: true, enableHighAccuracy: true });
+ map_all.on('locationfound', (e) => {
+ if (firstLocate) map_all.setView(e.latlng, 16);
+
+ if (!map_all._userMarker) {
+ map_all._userMarker = L.marker(e.latlng).addTo(map_all).bindPopup("");
+
+ map_all._userMarker.on("popupopen", () => {
+ const div = document.createElement("div");
+ div.className = 'marker_popup'
+ div.innerHTML = `
Ви тут!
`;
+ map_all._userMarker.setPopupContent(div);
+ });
+ } else {
+ map_all._userMarker.setLatLng(e.latlng);
+ }
+
+ firstLocate = false;
+ });
},
- added: ({ type, data }) => {
+ added({ type, data }) {
for (let index = 0; index < data.length; index++) {
const element = data[index];
let posPersonal, posGroup;
@@ -108,6 +161,13 @@ const Territory_Map = {
}
}
+ this.marker({
+ id: element.id,
+ type: 'homestead',
+ free: element.working ? 0 : 1,
+ geo: element.geo
+ })
+
} else {
posPersonal = Home.personal.house.list.map(e => e.id).indexOf(element.id);
posGroup = Home.group.house.list.map(e => e.id).indexOf(element.id);
@@ -119,6 +179,13 @@ const Territory_Map = {
fillOpacity: 0.8
}
}
+
+ this.marker({
+ id: element.id,
+ type: 'house',
+ free: element.entrance.quantity - element.entrance.working,
+ geo: element.geo
+ })
}
const polygon = L.polygon(element.points, polygonOptions).addTo(map_all);
@@ -129,13 +196,14 @@ const Territory_Map = {
polygon.on("popupopen", () => {
const div = document.createElement("div");
let text = () => {
- if (posPersonal != -1) return "
Моя територія"
- else if (posGroup != -1) return "
Групова територія"
- return ""
+ if (posPersonal != -1) return `
Моя територія ${element.title} ${element.number}
Перейти до території`
+ else if (posGroup != -1) return `
Групова територія ${element.title} ${element.number}
Перейти до території`
+ return `
${element.title} ${element.number}
`
}
- div.innerHTML = `${text()} ${element.title} ${element.number}`;
- div.className = "leaflet_drop"
+ div.className = 'marker_popup'
+ div.innerHTML = `${text()}`;
+ if (USER.possibilities.can_manager_territory || USER.mode == 2) div.innerHTML += `
Керування`;
polygon.setPopupContent(div);
});
@@ -144,40 +212,29 @@ const Territory_Map = {
}
},
- marker: ({ data, personal = false, group = false }) => {
- console.log(data);
+ marker({ id, type, free, geo }) {
+ if (!USER.possibilities.can_manager_territory || USER.mode != 2) return;
+ if (free <= 0) return;
- for (let index = 0; index < data.length; index++) {
- const element = data[index];
+ const redDot = L.divIcon({
+ className: "marker",
+ html: `${free}`,
+ iconSize: [30, 30],
+ iconAnchor: [15, 15]
+ });
+ // создаём маркер
+ const marker = L.marker([geo.lat, geo.lng], { icon: redDot }).addTo(type == 'homestead' ? free_homesteads : free_entrance);
+ marker.bindPopup("");
- console.log(element);
+ // при открытии popup генерим div заново
+ marker.on("popupopen", () => {
+ const div = document.createElement("div");
+ div.className = 'marker_popup'
+ div.innerHTML = `
Перейти до території`;
- const redDot = L.divIcon({
- className: "leaflet_drop",
- html: `
`,
- iconSize: [16, 16],
- iconAnchor: [8, 8]
- });
-
- // создаём маркер
- const marker = L.marker([element.geo.lat, element.geo.lng], { icon: redDot }).addTo(map_all);
- marker.bindPopup("");
-
- // при открытии popup генерим div заново
- marker.on("popupopen", () => {
- const div = document.createElement("div");
- let text = () => {
- if (personal) return "Моя територія"
- else if (group) return "Групова територія"
- return ""
- }
- div.innerHTML = text();
-
- marker.setPopupContent(div);
- });
-
- }
+ marker.setPopupContent(div);
+ });
}
}
}
\ No newline at end of file
diff --git a/web/lib/pages/territory/map/style.css b/web/lib/pages/territory/map/style.css
index 0ad68cb..e9cd083 100644
--- a/web/lib/pages/territory/map/style.css
+++ b/web/lib/pages/territory/map/style.css
@@ -1,11 +1,53 @@
.page-territory_map {
- width: 100%;
+ width: 100%;
display: flex;
position: relative;
}
+
.page-territory_map>#map {
margin: 20px;
width: calc(100% - 40px);
height: calc(100% - 40px);
border-radius: calc(var(--border-radius) - 5px);
+}
+
+.page-territory_map .marker {
+ background: var(--ColorThemes2);
+ color: var(--ColorThemes3);
+ font-size: var(--FontSize1);
+ border-radius: var(--border-radius);
+ border: 2px solid var(--ColorThemes3);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.page-territory_map .marker_popup {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ margin: 0 2px;
+}
+
+.page-territory_map .marker_popup>p {
+ margin: 0;
+}
+.page-territory_map .marker_popup>span {
+ margin: 0;
+}
+
+.page-territory_map .marker_popup>a {
+ color: var(--ColorThemes3);
+ cursor: pointer;
+ border-radius: calc(var(--border-radius) - 8px);
+ padding: 5px 10px;
+ min-width: fit-content;
+ background: var(--PrimaryColor);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 400;
+ font-size: var(--FontSize1);
+ min-width: calc(100% - 15px);
}
\ No newline at end of file
diff --git a/web/lib/router/routes.js b/web/lib/router/routes.js
index 1076ef3..5a0b3a0 100644
--- a/web/lib/router/routes.js
+++ b/web/lib/router/routes.js
@@ -94,12 +94,55 @@ Router
pageActive();
})
-function pageActive(element) {
- const active = document.querySelector("nav li [data-state='active']");
- if (active) active.setAttribute('data-state', '');
+// function pageActive(element) {
+// const active = document.querySelector("nav-item[data-state='active']");
+// if (active) active.setAttribute('data-state', '');
+// if (element) {
+// const target = document.getElementById(`menu-${element}`);
+// if (target) target.setAttribute('data-state', 'active');
+// }
+// }
+function pageActive(element) {
+ // 1. Знаходимо контейнер меню
+ const navContainer = document.querySelector('navigation-container');
+
+ if (!navContainer) {
+ console.warn('Компонент
не знайдено.');
+ return;
+ }
+
+ // 2. Видаляємо активний стан у всіх елементів.
+ // Шукаємо як у Light DOM (через querySelectorAll на документі),
+ // так і у Shadow DOM (через shadowRoot).
+ const activeInLight = document.querySelector("nav-item[data-state='active']");
+ if (activeInLight) {
+ activeInLight.setAttribute('data-state', '');
+ }
+
+ const activeInShadow = navContainer.shadowRoot.querySelector("nav-item[data-state='active']");
+ if (activeInShadow) {
+ activeInShadow.setAttribute('data-state', '');
+ }
+
+ // 3. Знаходимо цільовий елемент
if (element) {
- const target = document.getElementById(`nav-${element}`);
- if (target) target.setAttribute('data-state', 'active');
+ const targetId = `menu-${element}`;
+ let target = null;
+
+ // Спробуємо знайти в основному DOM
+ target = document.getElementById(targetId);
+
+ // Якщо не знайдено, шукаємо у Shadow DOM контейнера
+ if (!target) {
+ // Використовуємо querySelector для пошуку по всьому shadowRoot
+ // Якщо елементи переміщені у itemsHiddenContainer, вони будуть тут
+ target = navContainer.shadowRoot.querySelector(`#${targetId}`);
+ }
+
+ // 4. Встановлюємо активний стан, якщо знайдено
+ if (target) {
+ target.setAttribute('data-state', 'active');
+ }
}
}
\ No newline at end of file
diff --git a/web/sw.js b/web/sw.js
index 5eb5338..bc70f4a 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -1,4 +1,4 @@
-const STATIC_CACHE_NAME = 'v2.0.103';
+const STATIC_CACHE_NAME = 'v2.2.1';
const FILES_TO_CACHE = [
'/',
@@ -7,7 +7,12 @@ const FILES_TO_CACHE = [
"/lib/router/router.js",
"/lib/router/routes.js",
- "/lib/customElements/notification.js",
+ "/lib/customElements/notifManager.js",
+ "/lib/customElements/pwaInstallBanner.js",
+ "/lib/customElements/swipeUpdater.js",
+ "/lib/customElements/menuContainer.js",
+ "/lib/customElements/territoryCard.js",
+ "/lib/customElements/smartSelect.js",
"/lib/components/leaflet/leaflet.css",
"/lib/components/leaflet/leaflet.js",
@@ -19,12 +24,9 @@ const FILES_TO_CACHE = [
"/lib/components/cloud.js",
- "/lib/components/metrics.js",
-
"/lib/components/clipboard.js",
"/lib/components/colorGroup.js",
"/lib/components/makeid.js",
- "/lib/components/swipeUpdater.js",
"/lib/components/detectBrowser.js",
"/lib/components/detectOS.js",
"/lib/components/formattedDate.js",
@@ -141,7 +143,7 @@ self.addEventListener("push", event => {
try { data = event.data.json(); } catch { data = { title: "Повідомлення", body: event.data?.text() }; }
console.log('[ServiceWorker] ', data);
-
+
const title = data.title || "Повідомлення";
const options = {
@@ -157,7 +159,7 @@ self.addEventListener("push", event => {
self.addEventListener("notificationclick", event => {
event.notification.close();
event.waitUntil(
- clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
+ clients.matchAll({ type: "window", includeUncontrolled: true }).then(clientList => {
for (const client of clientList) {
if (client.url === event.notification.data && "focus" in client) return client.focus();
}
diff --git a/ws/middleware/pushToMetrics.js b/ws/middleware/pushToMetrics.js
deleted file mode 100644
index 3b7a978..0000000
--- a/ws/middleware/pushToMetrics.js
+++ /dev/null
@@ -1,13 +0,0 @@
-async function pushToMetrics(metric) {
- if (!metric || !metric.type) return;
-
- const payload = { ...metric, timestamp: Date.now() };
-
- fetch("http://metrics:4005/push", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload)
- }).catch(err => console.error("Metrics push error:", err));
-}
-
-module.exports = { pushToMetrics };
\ No newline at end of file
diff --git a/ws/package.json b/ws/package.json
index 2fc0007..a8a5627 100644
--- a/ws/package.json
+++ b/ws/package.json
@@ -13,6 +13,7 @@
"sqlite3": "^5.1.7",
"url": "^0.11.4",
"ws": "^8.18.0",
- "dotenv": "^17.2.0"
+ "dotenv": "^17.2.0",
+ "web-push": "^3.6.7"
}
}
diff --git a/ws/routes/message.js b/ws/routes/message.js
index caed596..b25e7ab 100644
--- a/ws/routes/message.js
+++ b/ws/routes/message.js
@@ -2,16 +2,9 @@ const { updateApartment } = require("../services/apartments.service");
const { updateBuilding } = require("../services/buildings.service");
const { lockingStand, unlockingStand, updateStand } = require("../services/stand.service");
const { broadcast } = require("../utils/broadcaster");
-const { pushToMetrics } = require("../middleware/pushToMetrics");
module.exports = async (wss, ws, message) => {
try {
- pushToMetrics({
- type: "ws_out",
- length: message.length,
- timestamp: Date.now()
- });
-
switch (message.type) {
case "apartment":
await updateApartment(ws.user, message.data);
diff --git a/ws/services/stand.service.js b/ws/services/stand.service.js
index c5f9e54..36f5307 100644
--- a/ws/services/stand.service.js
+++ b/ws/services/stand.service.js
@@ -1,9 +1,10 @@
const db = require("../config/db");
+const Notification = require("../utils/notification.js");
function lockingStand(user, data) {
return new Promise((resolve, reject) => {
const sheepId = Number(data.sheep_id) || null;
-
+
if (!user.possibilities.can_view_stand) {
return reject(new Error("Forbidden: no rights to view stand"));
}
@@ -18,7 +19,7 @@ function lockingStand(user, data) {
function unlockingStand(user, data) {
return new Promise((resolve, reject) => {
const sheepId = Number(data.sheep_id) || null;
-
+
if (!user.possibilities.can_view_stand) {
return reject(new Error("Forbidden: no rights to view stand"));
}
@@ -33,7 +34,7 @@ function unlockingStand(user, data) {
function updateStand(user, data) {
return new Promise((resolve, reject) => {
const sheepId = Number(data.sheep_id) || null;
-
+
if (!user.possibilities.can_view_stand) {
return reject(new Error("Forbidden: no rights to view stand"));
}
@@ -52,11 +53,36 @@ function updateStand(user, data) {
const insertSql = `
INSERT INTO stand_schedule_history
- (stand_schedule_id, sheep_id, created_at)
- VALUES (?, ?, ?)`;
+ (stand_schedule_id, sheep_id, editor, created_at)
+ VALUES (?, ?, ?, ?)`;
- db.run(insertSql, [Number(data.id), sheepId, Date.now()], function (err) {
+ db.run(insertSql, [Number(data.id), sheepId, user.id, Date.now()], function (err) {
if (err) return reject(err);
+
+ if (sheepId === null) {
+ let text = [
+ 'Звільнилося місце на одному зі стендів. Хто перший — той встигне 😉',
+ 'Є одне вільне місце на стенді. Запис відкрито — не проґавте 😉',
+ 'У одного зі стендів з’явилося вільне місце. Встигніть записатися!',
+ 'Раптова можливість! На стенді є вільне місце. Забронюйте його зараз 📋',
+ 'Одне місце стало вільним. Можливо, це саме ваше? 😉',
+ 'Стенд чекає нового учасника. Вільне місце вже доступне 📋',
+ 'Є шанс приєднатися — одне місце звільнилося 😊',
+ 'Вільне місце на стенді довго не чекатиме. Записуйтеся!',
+ 'Оголошуємо міні-набір: доступне одне місце на стенді.',
+ 'Щойно звільнилося місце. Хто швидший — той з нами 🚀',
+ 'З’явилася можливість долучитися до стенду. Кількість місць обмежена!',
+ 'Останнє вільне місце на стенді шукає свого власника.'
+ ];
+ let randomMessage = text[Math.floor(Math.random() * text.length)];
+
+ Notification.sendStand({
+ title: "Звільнилось місце",
+ body: randomMessage,
+ page: `/stand/card/${data.stand_id}`
+ });
+ }
+
resolve({ update: "ok", id: data.id, historyId: this.lastID });
});
});
diff --git a/ws/utils/notification.js b/ws/utils/notification.js
new file mode 100644
index 0000000..e791ea0
--- /dev/null
+++ b/ws/utils/notification.js
@@ -0,0 +1,152 @@
+const db = require("../config/db");
+const webpush = require('web-push');
+
+const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY;
+const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY;
+
+webpush.setVapidDetails(
+ 'mailto:rozenrod320@gmail.com',
+ VAPID_PUBLIC_KEY,
+ VAPID_PRIVATE_KEY
+);
+
+class Notification {
+ async sendSheep({ sheep_id, title, body, page }) {
+ const sql = `
+ SELECT * FROM subscription
+ WHERE sheep_id = ?
+ ORDER BY id
+ `;
+
+ db.all(sql, [sheep_id], async (err, rows) => {
+ if (err) {
+ console.error('DB error:', err.message);
+ return;
+ }
+
+ if (!rows.length) {
+ console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
+ return;
+ }
+
+ console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
+
+ const payload = JSON.stringify({
+ title: title ?? "Тестове повідомлення",
+ body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
+ url: `https://${process.env.DOMAIN}${page ?? ""}`
+ });
+
+ const results = await Promise.allSettled(rows.map(row => {
+ const subscription = {
+ endpoint: row.endpoint,
+ keys: JSON.parse(row.keys),
+ };
+ return webpush.sendNotification(subscription, payload);
+ }));
+
+ const failed = results.filter(r => r.status === 'rejected').length;
+ console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
+ });
+ }
+
+ async sendGroup({ group_id, title, body, page }) {
+ const sql = `
+ SELECT
+ subscription.*
+ FROM
+ subscription
+ JOIN
+ sheeps
+ ON
+ sheeps.id = subscription.sheep_id
+ WHERE
+ sheeps.group_id = ?
+ ORDER BY
+ subscription.id;
+ `;
+
+ db.all(sql, [group_id], async (err, rows) => {
+ if (err) {
+ console.error('DB error:', err.message);
+ return;
+ }
+
+ if (!rows.length) {
+ console.log(`🐑 No subscriptions found for sheep_id: ${sheep_id}`);
+ return;
+ }
+
+ console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
+
+ const payload = JSON.stringify({
+ title: title ?? "Тестове повідомлення",
+ body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
+ url: `https://${process.env.DOMAIN}${page ?? ""}`
+ });
+
+ const results = await Promise.allSettled(rows.map(row => {
+ const subscription = {
+ endpoint: row.endpoint,
+ keys: JSON.parse(row.keys),
+ };
+ return webpush.sendNotification(subscription, payload);
+ }));
+
+ const failed = results.filter(r => r.status === 'rejected').length;
+ console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
+ });
+ }
+
+ async sendStand({ title, body, page }) {
+ const sql = `
+ SELECT
+ subscription.*
+ FROM
+ subscription
+ JOIN
+ sheeps
+ ON sheeps.id = subscription.sheep_id
+ JOIN
+ possibilities
+ ON possibilities.sheep_id = sheeps.id
+ WHERE
+ possibilities.can_view_stand = '1'
+ ORDER BY
+ subscription.id;
+ `;
+
+ db.all(sql, async (err, rows) => {
+ if (err) {
+ console.error('DB error:', err.message);
+ return;
+ }
+
+ if (!rows.length) {
+ console.log(`🐑 No subscriptions`);
+ return;
+ }
+
+ console.log(`📨 Sending notification to ${rows.length} subscriptions...`);
+
+ const payload = JSON.stringify({
+ title: title ?? "Тестове повідомлення",
+ body: body ?? "Ви успішно підписалися на отримання push повідомлень!",
+ url: `https://${process.env.DOMAIN}${page ?? ""}`
+ });
+
+ const results = await Promise.allSettled(rows.map(row => {
+ const subscription = {
+ endpoint: row.endpoint,
+ keys: JSON.parse(row.keys),
+ };
+ return webpush.sendNotification(subscription, payload);
+ }));
+
+ const failed = results.filter(r => r.status === 'rejected').length;
+ console.log(`✅ Sent: ${rows.length - failed}, ❌ Failed: ${failed}`);
+ });
+ }
+};
+
+module.exports = new Notification();
\ No newline at end of file
diff --git a/ws/ws.js b/ws/ws.js
index 5bf1c03..1176a13 100644
--- a/ws/ws.js
+++ b/ws/ws.js
@@ -2,7 +2,6 @@ const WebSocket = require("ws");
const { URL } = require('url');
const { routeMessage } = require("./routes");
const { auth } = require("./middleware/auth");
-const { pushToMetrics } = require("./middleware/pushToMetrics");
const { setupPing } = require("./utils/ping");
require("dotenv").config();
@@ -29,19 +28,10 @@ wss.on("connection", async (ws, request) => {
ws.user = user;
ws.send(JSON.stringify({ connection: "success", api_version, user: {name: ws.user.name, id: ws.user.id } }));
- pushToMetrics({ type: "connection_status", status: "online", api_version, user: {name: ws.user.name, id: ws.user.id } });
-
// Periodic ping to maintain a connection
setupPing(ws);
ws.on("message", (raw) => {
-
- pushToMetrics({
- type: "ws_in",
- length: raw.length,
- timestamp: Date.now()
- });
-
try {
const message = JSON.parse(raw);
routeMessage(wss, ws, message);
@@ -53,7 +43,6 @@ wss.on("connection", async (ws, request) => {
ws.on("close", () => {
console.log("🔌 Client disconnected");
- pushToMetrics({ type: "connection_status", status: "offline" });
});
ws.on("error", (err) => console.error("❌ WS error:", err));
} catch (err) {