Fügt Funktionen zum Zusammenführen und Entfernen von Duplikaten vordefinierter Aktivitäten hinzu. Implementiert die entsprechenden Controller-Methoden und Routen. Aktualisiert die Benutzeroberfläche in PredefinedActivities.vue, um die neuen Funktionen zur Verfügung zu stellen und die Aktivitäten nach Namen und Code zu sortieren.
This commit is contained in:
@@ -61,3 +61,24 @@ export const searchPredefinedActivities = async (req, res) => {
|
||||
res.status(500).json({ error: 'Error searching predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
export const mergePredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const { sourceId, targetId } = req.body;
|
||||
await predefinedActivityService.mergeActivities(sourceId, targetId);
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[mergePredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error merging predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deduplicatePredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const result = await predefinedActivityService.deduplicateActivities();
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deduplicatePredefinedActivities] - Error:', error);
|
||||
res.status(500).json({ error: 'Error deduplicating predefined activities' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
getPredefinedActivityById,
|
||||
updatePredefinedActivity,
|
||||
searchPredefinedActivities,
|
||||
mergePredefinedActivities,
|
||||
deduplicatePredefinedActivities,
|
||||
} from '../controllers/predefinedActivityController.js';
|
||||
import multer from 'multer';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
@@ -22,6 +24,8 @@ router.get('/:id', authenticate, getPredefinedActivityById);
|
||||
router.put('/:id', authenticate, updatePredefinedActivity);
|
||||
router.post('/:id/image', authenticate, upload.single('image'), uploadPredefinedActivityImage);
|
||||
router.get('/search/query', authenticate, searchPredefinedActivities);
|
||||
router.post('/merge', authenticate, mergePredefinedActivities);
|
||||
router.post('/deduplicate', authenticate, deduplicatePredefinedActivities);
|
||||
router.get('/:id/image/:imageId', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { id, imageId } = req.params;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
|
||||
import sequelize from '../database.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
class PredefinedActivityService {
|
||||
@@ -33,7 +37,13 @@ class PredefinedActivityService {
|
||||
|
||||
async getAllPredefinedActivities() {
|
||||
console.log('[PredefinedActivityService::getAllPredefinedActivities] - Fetching all predefined activities');
|
||||
return await PredefinedActivity.findAll();
|
||||
return await PredefinedActivity.findAll({
|
||||
order: [
|
||||
[sequelize.literal('code IS NULL'), 'ASC'], // Non-null codes first
|
||||
['code', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async getPredefinedActivityById(id) {
|
||||
@@ -58,10 +68,81 @@ class PredefinedActivityService {
|
||||
{ code: { [Op.like]: `%${q}%` } },
|
||||
],
|
||||
},
|
||||
order: [['name', 'ASC']],
|
||||
order: [
|
||||
[sequelize.literal('code IS NULL'), 'ASC'],
|
||||
['code', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
limit: Math.min(parseInt(limit || 20, 10), 50),
|
||||
});
|
||||
}
|
||||
|
||||
async mergeActivities(sourceId, targetId) {
|
||||
console.log(`[PredefinedActivityService::mergeActivities] - Merge ${sourceId} -> ${targetId}`);
|
||||
if (!sourceId || !targetId) throw new Error('sourceId and targetId are required');
|
||||
if (Number(sourceId) === Number(targetId)) throw new Error('sourceId and targetId must differ');
|
||||
|
||||
const tx = await sequelize.transaction();
|
||||
try {
|
||||
const source = await PredefinedActivity.findByPk(sourceId, { transaction: tx });
|
||||
const target = await PredefinedActivity.findByPk(targetId, { transaction: tx });
|
||||
if (!source) throw new Error('Source activity not found');
|
||||
if (!target) throw new Error('Target activity not found');
|
||||
|
||||
// Reassign references
|
||||
await DiaryDateActivity.update(
|
||||
{ predefinedActivityId: targetId },
|
||||
{ where: { predefinedActivityId: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
await GroupActivity.update(
|
||||
{ customActivity: targetId },
|
||||
{ where: { customActivity: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
await PredefinedActivityImage.update(
|
||||
{ predefinedActivityId: targetId },
|
||||
{ where: { predefinedActivityId: sourceId }, transaction: tx }
|
||||
);
|
||||
|
||||
// Finally delete source
|
||||
await source.destroy({ transaction: tx });
|
||||
|
||||
await tx.commit();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
await tx.rollback();
|
||||
console.error('[PredefinedActivityService::mergeActivities] - Error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deduplicateActivities() {
|
||||
console.log('[PredefinedActivityService::deduplicateActivities] - Start');
|
||||
const all = await PredefinedActivity.findAll();
|
||||
const nameToActivities = new Map();
|
||||
for (const activity of all) {
|
||||
const key = (activity.name || '').trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!nameToActivities.has(key)) nameToActivities.set(key, []);
|
||||
nameToActivities.get(key).push(activity);
|
||||
}
|
||||
let mergedCount = 0;
|
||||
let groupCount = 0;
|
||||
for (const list of nameToActivities.values()) {
|
||||
if (!list || list.length <= 1) continue;
|
||||
groupCount++;
|
||||
// Stable target: kleinste ID
|
||||
list.sort((a, b) => a.id - b.id);
|
||||
const target = list[0];
|
||||
for (const src of list.slice(1)) {
|
||||
await this.mergeActivities(src.id, target.id);
|
||||
mergedCount++;
|
||||
}
|
||||
}
|
||||
console.log('[PredefinedActivityService::deduplicateActivities] - Done', { mergedCount, groupCount });
|
||||
return { mergedCount, groupCount };
|
||||
}
|
||||
}
|
||||
|
||||
export default new PredefinedActivityService();
|
||||
|
||||
@@ -6,9 +6,25 @@
|
||||
<div class="toolbar">
|
||||
<button @click="startCreate" class="btn-primary">Neu</button>
|
||||
<button @click="reload" class="btn-secondary">Neu laden</button>
|
||||
<div>
|
||||
<div>
|
||||
<button @click="deduplicate" class="btn-secondary">Doppelungen zusammenführen</button>
|
||||
</div
|
||||
<div class="merge-tools">
|
||||
<select v-model="mergeSourceId">
|
||||
<option disabled value="">Quelle wählen…</option>
|
||||
<option v-for="a in activities" :key="'s'+a.id" :value="a.id">{{ formatItem(a) }}</option>
|
||||
</select>
|
||||
<span>→</span>
|
||||
<select v-model="mergeTargetId">
|
||||
<option disabled value="">Ziel wählen…</option>
|
||||
<option v-for="a in activities" :key="'t'+a.id" :value="a.id">{{ formatItem(a) }}</option>
|
||||
</select>
|
||||
<button class="btn-secondary" :disabled="!canMerge" @click="mergeSelected">Zusammenführen</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="items">
|
||||
<li v-for="a in activities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
|
||||
<li v-for="a in sortedActivities" :key="a.id" :class="{ active: selectedActivity && selectedActivity.id === a.id }" @click="select(a)">
|
||||
<div class="title">
|
||||
<strong>{{ a.code ? '[' + a.code + '] ' : '' }}{{ a.name }}</strong>
|
||||
</div>
|
||||
@@ -76,12 +92,33 @@ export default {
|
||||
editModel: null,
|
||||
images: [],
|
||||
selectedFile: null,
|
||||
mergeSourceId: '',
|
||||
mergeTargetId: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
sortedActivities() {
|
||||
return [...(this.activities || [])].sort((a, b) => {
|
||||
const ac = (a.code || '').toLocaleLowerCase('de-DE');
|
||||
const bc = (b.code || '').toLocaleLowerCase('de-DE');
|
||||
const aEmpty = ac === '';
|
||||
const bEmpty = bc === '';
|
||||
if (aEmpty !== bEmpty) return aEmpty ? 1 : -1; // leere Codes nach hinten
|
||||
if (ac < bc) return -1; if (ac > bc) return 1;
|
||||
const an = (a.name || '').toLocaleLowerCase('de-DE');
|
||||
const bn = (b.name || '').toLocaleLowerCase('de-DE');
|
||||
if (an < bn) return -1; if (an > bn) return 1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
canMerge() {
|
||||
return this.mergeSourceId && this.mergeTargetId && String(this.mergeSourceId) !== String(this.mergeTargetId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async reload() {
|
||||
const r = await apiClient.get('/predefined-activities');
|
||||
this.activities = r.data;
|
||||
this.activities = r.data || [];
|
||||
},
|
||||
async select(a) {
|
||||
this.selectedActivity = a;
|
||||
@@ -90,6 +127,18 @@ export default {
|
||||
this.images = images || [];
|
||||
this.editModel = { ...activity };
|
||||
},
|
||||
formatItem(a) {
|
||||
return `${a.code ? '[' + a.code + '] ' : ''}${a.name}`;
|
||||
},
|
||||
async mergeSelected() {
|
||||
if (!this.canMerge) return;
|
||||
const src = this.mergeSourceId; const tgt = this.mergeTargetId;
|
||||
if (!confirm(`Eintrag #${src} in #${tgt} zusammenführen?\nAlle Verknüpfungen werden auf das Ziel umgebogen, die Quelle wird gelöscht.`)) return;
|
||||
await apiClient.post('/predefined-activities/merge', { sourceId: src, targetId: tgt });
|
||||
this.mergeSourceId = '';
|
||||
this.mergeTargetId = '';
|
||||
await this.reload();
|
||||
},
|
||||
startCreate() {
|
||||
this.selectedActivity = null;
|
||||
this.images = [];
|
||||
@@ -135,6 +184,11 @@ export default {
|
||||
// Nach Upload Details neu laden
|
||||
await this.select(this.editModel);
|
||||
this.selectedFile = null;
|
||||
},
|
||||
async deduplicate() {
|
||||
if (!confirm('Alle Aktivitäten mit identischem Namen werden zusammengeführt. Fortfahren?')) return;
|
||||
await apiClient.post('/predefined-activities/deduplicate', {});
|
||||
await this.reload();
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
@@ -164,6 +218,8 @@ export default {
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.merge-tools { display: inline-flex; align-items: center; gap: .35rem; margin-left: auto; }
|
||||
select { max-width: 220px; }
|
||||
.items {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user