Files
Aberwyn/Aberwyn/wwwroot/js/budget.js
Elias Jansson e3eb2dc7cb
All checks were successful
continuous-integration/drone/push Build is passing
Budget page improvements
2025-06-09 16:11:59 +02:00

832 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
var app = angular.module('budgetApp', []);
console.log("budget.js loaded");
app.controller('BudgetController', function ($scope, $http) {
$scope.budget = null;
$scope.loading = false;
$scope.error = null;
$scope.menuOpen = false;
$scope.chartMode = "pie";
const today = new Date();
$scope.selectedYear = today.getFullYear();
$scope.selectedMonth = today.getMonth() + 1;
$scope.tempMonth = $scope.monthNames?.[today.getMonth()] || "";
$scope.tempYear = $scope.selectedYear;
$scope.monthNames = ["Januari", "Februari", "Mars", "April", "Maj", "Juni", "Juli", "Augusti", "September", "Oktober", "November", "December"];
$scope.getMonthName = function (month) {
return $scope.monthNames[month - 1] || "";
};
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.toggleMenu = function (e) {
e.stopPropagation();
$scope.menuOpen = !$scope.menuOpen;
};
$scope.menuVisible = false;
$scope.menuItem = null;
$scope.menuStyle = {};
$scope.openItemMenu = function ($event, item) {
const rect = $event.currentTarget.getBoundingClientRect();
console.log("Menu position:", rect);
$scope.menuItem = item;
$scope.menuItem.category = $scope.budget.categories.find(c => c.items.includes(item));
$scope.menuStyle = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`
};
$scope.menuVisible = true;
};
$scope.setItemType = function (item, type) {
if (type === 'expense') {
item.isExpense = true;
item.includeInSummary = true;
} else if (type === 'income') {
item.isExpense = false;
item.includeInSummary = true;
} else if (type === 'saving') {
item.isExpense = false;
item.includeInSummary = false;
}
$scope.menuVisible = false;
};
// Klick utanför stänger popup
document.addEventListener('click', function (e) {
const menu = document.querySelector('.item-floating-menu');
if (menu && !$scope.menuVisible) return;
const isInside = menu?.contains(e.target);
const isButton = e.target.closest('.icon-button');
if (!isInside && !isButton) {
$scope.$apply(() => $scope.menuVisible = false);
}
});
document.addEventListener('click', function () {
$scope.$apply(() => {
$scope.menuOpen = false;
});
});
$scope.showToast = function (message, isError = false) {
const toast = document.createElement("div");
toast.className = "toast" + (isError ? " error" : "") + " show";
toast.innerText = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 300);
}, 3000);
};
$scope.loadBudget = function () {
$scope.loading = true;
$scope.error = null;
$scope.budget = null;
$http.get(`/api/budget/${$scope.selectedYear}/${$scope.selectedMonth}`)
.then(function (response) {
const raw = response.data;
if (raw && raw.Categories) {
const categories = raw.Categories.map(cat => ({
id: cat.Id,
name: cat.Name,
color: cat.Color,
editing: false,
allowDrag: false,
items: (cat.Items || []).map((item, index) => {
const definition = $scope.itemDefinitions.find(d => d.id === item.BudgetItemDefinitionId || d.Id === item.BudgetItemDefinitionId);
return {
id: item.Id,
name: item.Name,
amount: parseFloat(item.Amount),
isExpense: item.IsExpense === true,
includeInSummary: item.IncludeInSummary === true,
order: item.Order ?? index,
budgetItemDefinitionId: item.BudgetItemDefinitionId,
definitionName: definition?.Name || null
};
}).sort((a, b) => a.order - b.order)
}));
$scope.budget = {
id: raw.Id,
year: raw.Year,
month: raw.Month,
categories: categories.sort((a, b) => a.order - b.order)
};
} else {
$scope.budget = { categories: [] };
}
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
})
.catch(function (error) {
if (error.status === 404) {
$scope.budget = { categories: [] };
} else {
$scope.error = "Kunde inte ladda budgetdata.";
$scope.showToast("Fel vid laddning av budgetdata", true);
console.error("Budget API error:", error);
}
})
.finally(function () {
$scope.loading = false;
setTimeout($scope.drawCategoryChart, 0);
});
};
$scope.saveCategory = function (category) {
if (category.newItemName && category.newItemAmount) {
const newItem = {
name: category.newItemName,
amount: parseFloat(category.newItemAmount),
isExpense: true,
includeInSummary: true,
budgetCategoryId: category.id,
};
$http.post("/api/budget/item", newItem)
.then(res => {
if (res.data && res.data.id) {
newItem.id = res.data.id;
category.items.push(newItem);
category.newItemName = "";
category.newItemAmount = "";
$scope.showToast("Post sparad!");
} else {
$scope.showToast("Fel vid sparande av ny post", true);
}
});
}
const payload = {
id: category.id,
name: category.name,
color: category.color,
budgetCategoryDefinitionId: category.budgetCategoryDefinitionId || null,
items: category.items.map((item, index) => ({
id: item.id,
name: item.name,
amount: item.amount,
isExpense: item.isExpense,
includeInSummary: item.includeInSummary,
budgetCategoryId: category.id,
order: index
}))
};
$http.put(`/api/budget/category/${category.id}`, payload)
.then(() => {
$scope.showToast("Kategori sparad!");
category.editing = false; // ✅ flytta hit
})
.catch(() => {
$scope.showToast("Fel vid sparande av kategori", true);
});
};
$scope.deleteCategory = function (category) {
if (!confirm("Vill du verkligen ta bort kategorin och alla dess poster?")) return;
$http.delete(`/api/budget/category/${category.id}`)
.then(() => {
$scope.budget.categories = $scope.budget.categories.filter(c => c.id !== category.id);
$scope.showToast("Kategori borttagen!");
})
.catch(err => {
console.error("Fel vid borttagning av kategori:", err);
$scope.showToast("Kunde inte ta bort kategori", true);
});
};
$scope.copyPreviousMonthSafe = function () {
console.log("click OK");
$scope.menuOpen = false;
setTimeout(function () {
$scope.copyPreviousMonth();
}, 10);
};
$scope.deleteMonth = function () {
if (!confirm("Vill du verkligen ta bort alla data för denna månad?")) return;
const year = $scope.selectedYear;
const month = $scope.selectedMonth;
$http.delete(`/api/budget/${year}/${month}`)
.then(() => {
$scope.showToast("Månad borttagen!");
$scope.loadBudget(); // tömmer sidan
})
.catch(err => {
console.error("Kunde inte ta bort månad:", err);
$scope.showToast("Fel vid borttagning av månad", true);
});
};
$scope.copyPreviousMonth = function () {
if (!confirm("Vill du kopiera föregående månad till den aktuella?")) {
return;
}
console.log("Försöker kopiera föregående månad...");
const year = $scope.selectedYear;
const month = $scope.selectedMonth;
$http.post(`/api/budget/copy/${year}/${month}`)
.then(() => {
$scope.showToast("Föregående månad kopierad!");
$scope.loadBudget();
})
.catch(err => {
console.error("Kunde inte kopiera föregående månad:", err);
if (err.status === 404) {
$scope.showToast("Ingen föregående månad att kopiera från", true);
} else if (err.status === 400) {
$scope.showToast("Data finns redan för denna månad", true);
} else {
$scope.showToast("Kunde inte kopiera", true);
}
});
};
$scope.cancelCategoryEdit = function (category) {
category.editing = false;
};
function getItemsFlat() {
if (!$scope.budget || !$scope.budget.categories) return [];
return $scope.budget.categories.flatMap(c => c.items || []);
}
$scope.getCategorySum = function (category) {
return (category.items || []).reduce((sum, i) => sum + i.amount, 0);
};
$scope.getTotalIncome = function () {
return getItemsFlat().filter(i => !i.isExpense && i.includeInSummary).reduce((sum, i) => sum + i.amount, 0);
};
$scope.getTotalExpense = function () {
return getItemsFlat().filter(i => i.isExpense && i.includeInSummary).reduce((sum, i) => sum + i.amount, 0);
};
$scope.getTotalSaving = function () {
return getItemsFlat().filter(i => !i.isExpense && !i.includeInSummary).reduce((sum, i) => sum + i.amount, 0);
};
$scope.getLeftover = function () {
return $scope.getTotalIncome() - $scope.getTotalExpense();
};
function positionAddItemPopup(popup, triggerButton) {
const rect = popup.getBoundingClientRect();
const bottomSpace = window.innerHeight - rect.bottom;
// Om popupen sticker utanför skärmen, placera den ovanför
if (bottomSpace < 50) {
popup.classList.add('above');
} else {
popup.classList.remove('above');
}
}
$scope.applyMonthSelection = function () {
const monthIndex = $scope.monthNames.indexOf($scope.tempMonth);
if (monthIndex >= 0 && $scope.tempYear) {
$scope.selectedMonth = monthIndex + 1;
$scope.selectedYear = parseInt($scope.tempYear);
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.showMonthPicker = false;
$scope.loadBudget();
}
};
$scope.previousMonth = function () {
if ($scope.selectedMonth === 1) {
$scope.selectedMonth = 12;
$scope.selectedYear--;
} else {
$scope.selectedMonth--;
}
$scope.tempMonth = $scope.getMonthName($scope.selectedMonth);
$scope.tempYear = $scope.selectedYear;
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.loadBudget();
};
$scope.nextMonth = function () {
if ($scope.selectedMonth === 12) {
$scope.selectedMonth = 1;
$scope.selectedYear++;
} else {
$scope.selectedMonth++;
}
$scope.tempMonth = $scope.getMonthName($scope.selectedMonth);
$scope.tempYear = $scope.selectedYear;
$scope.selectedMonthName = $scope.getMonthName($scope.selectedMonth);
$scope.loadBudget();
};
$scope.handleCategoryDrop = function (data, targetCategory) {
if (data.type !== 'category') return; // ⛔ stoppa om det är ett item-drag
const categories = $scope.budget.categories;
const draggedIndex = categories.findIndex(c => c.id === data.categoryId);
const targetIndex = categories.findIndex(c => c.id === targetCategory.id);
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) return;
const moved = categories.splice(draggedIndex, 1)[0];
categories.splice(targetIndex, 0, moved);
categories.forEach((cat, i) => {
cat.order = i;
});
const payload = $scope.budget.categories.map(cat => ({
id: cat.id,
name: cat.name,
color: cat.color,
order: cat.order,
year: $scope.selectedYear,
month: $scope.selectedMonth,
items: [] // tom lista backend kräver den, men vi använder den inte här
}));
$http.put("/api/budget/category/order", payload)
.then(() => $scope.showToast("Kategorier omordnade!"))
.catch(() => $scope.showToast("Fel vid uppdatering av ordning", true));
};
$scope.handleItemDrop = function ({ data, targetCategory, targetIndex }) {
const sourceCategory = $scope.budget.categories.find(c => c.id === data.fromCategoryId);
const draggedItem = sourceCategory?.items.find(i => i.id === data.itemId);
if (!draggedItem || !targetCategory) return;
// Ta bort från ursprung
const draggedIndex = sourceCategory.items.findIndex(i => i.id === draggedItem.id);
if (draggedIndex !== -1) {
sourceCategory.items.splice(draggedIndex, 1);
}
// Uppdatera värden
draggedItem.budgetCategoryId = targetCategory.id;
const indexToInsert = typeof targetIndex === 'number' ? targetIndex : targetCategory.items.length;
targetCategory.items.splice(indexToInsert, 0, draggedItem);
// Uppdatera ordning
targetCategory.items.forEach((item, i) => {
item.order = i;
});
// ✅ Skicka PUT för det flyttade itemet
const payload = {
id: draggedItem.id,
name: draggedItem.name,
amount: draggedItem.amount,
isExpense: draggedItem.isExpense,
includeInSummary: draggedItem.includeInSummary,
order: draggedItem.order,
budgetCategoryId: draggedItem.budgetCategoryId
};
$http.put(`/api/budget/item/${draggedItem.id}`, payload)
.then(() => {
console.log(">>> Sparad!");
$scope.showToast("Post omplacerad!");
})
.catch(err => {
console.error("Kunde inte uppdatera post:", err);
$scope.showToast("Fel vid omplacering", true);
});
};
$scope.handleItemPreciseDrop = function (data, targetCategory, targetIndex) {
if (data.type !== 'item') return;
const sourceCategory = $scope.budget.categories.find(c => c.id === data.fromCategoryId);
const draggedItem = sourceCategory?.items.find(i => i.id === data.itemId);
if (!draggedItem || !targetCategory) return;
// Ta bort från ursprung
const draggedIndex = sourceCategory.items.findIndex(i => i.id === draggedItem.id);
if (draggedIndex !== -1) {
sourceCategory.items.splice(draggedIndex, 1);
}
// Uppdatera kategori och lägg till på exakt plats
draggedItem.budgetCategoryId = targetCategory.id;
targetCategory.items.splice(targetIndex, 0, draggedItem);
// Uppdatera ordning
targetCategory.items.forEach((item, i) => {
item.order = i;
});
// ✅ PUT för det flyttade itemet
const payload = {
id: draggedItem.id,
name: draggedItem.name,
amount: draggedItem.amount,
isExpense: draggedItem.isExpense,
includeInSummary: draggedItem.includeInSummary,
order: draggedItem.order,
budgetCategoryId: draggedItem.budgetCategoryId
};
$http.put(`/api/budget/item/${draggedItem.id}`, payload)
.then(() => {
console.log(">>> Sparad!");
$scope.showToast("Post omplacerad!");
})
.catch(err => {
console.error("Kunde inte uppdatera post:", err);
$scope.showToast("Fel vid omplacering", true);
});
};
$scope.updateItemType = function (item) {
if (item.type === 'income') {
item.isExpense = false;
item.includeInSummary = true;
} else if (item.type === 'expense') {
item.isExpense = true;
item.includeInSummary = true;
} else if (item.type === 'saving') {
item.isExpense = false;
item.includeInSummary = false;
}
};
$scope.setItemType = function (item, type) {
if (type === 'income') {
item.isExpense = false;
item.includeInSummary = true;
} else if (type === 'expense') {
item.isExpense = true;
item.includeInSummary = true;
} else if (type === 'saving') {
item.isExpense = false;
item.includeInSummary = false;
}
};
$scope.createNewCategory = function () {
const defaultName = "Ny kategori";
const newOrder = $scope.budget.categories.length; // sist i listan
const newCategory = {
name: defaultName,
color: "#666666",
year: $scope.selectedYear,
month: $scope.selectedMonth,
order: newOrder
};
$http.post("/api/budget/category", newCategory)
.then(res => {
if (res.data && res.data.id) {
$scope.budget.categories.push({
id: res.data.id,
name: defaultName,
color: "#666666",
order: newOrder,
items: [],
editing: true,
allowDrag: false
});
$scope.showToast("Kategori skapad!");
} else {
$scope.showToast("Misslyckades med att skapa kategori", true);
}
})
.catch(err => {
console.error("Fel vid skapande av kategori:", err);
$scope.showToast("Fel vid skapande av kategori", true);
});
};
$scope.addItem = function (category) {
if (!category.newItemName || !category.newItemAmount) return;
const tempId = `temp-${Date.now()}-${Math.random()}`; // temporärt ID
const newItem = {
id: tempId,
name: category.newItemName,
amount: parseFloat(category.newItemAmount),
isExpense: true,
includeInSummary: true,
budgetCategoryId: category.id
};
category.items.push(newItem);
category.newItemName = "";
category.newItemAmount = "";
};
$scope.deleteItem = function (category, item) {
console.log("Försöker ta bort:", item);
if (!item.id || item.id.toString().startsWith("temp-")) {
// Ta bort direkt om det är ett nytt (osparat) item
category.items = category.items.filter(i => i !== item);
$scope.showToast("Ospard post togs bort");
return;
}
if (!confirm("Vill du ta bort denna post?")) return;
$http.delete(`/api/budget/item/${item.id}`)
.then(() => {
category.items = category.items.filter(i => i.id !== item.id);
$scope.showToast("Post borttagen!");
})
.catch(err => {
console.error("Fel vid borttagning av item:", err);
$scope.showToast("Kunde inte ta bort posten", true);
});
};
$scope.handleItemDrop = function (event, data, targetCategory) {
console.log("Item drop received:", data, "to", targetCategory);
// Hitta källkategorin
const sourceCat = $scope.budget.categories.find(c => c.id === data.fromCategoryId);
if (!sourceCat) return;
const itemIndex = sourceCat.items.findIndex(i => i.id === data.itemId);
if (itemIndex === -1) return;
const [movedItem] = sourceCat.items.splice(itemIndex, 1);
targetCategory.items.push(movedItem);
$scope.$applyAsync();
};
$scope.handleItemPreciseDrop = function (data, targetCategory, targetIndex) {
console.log("Precise drop at index", targetIndex);
const sourceCat = $scope.budget.categories.find(c => c.id === data.fromCategoryId);
if (!sourceCat) return;
const itemIndex = sourceCat.items.findIndex(i => i.id === data.itemId);
if (itemIndex === -1) return;
const [movedItem] = sourceCat.items.splice(itemIndex, 1);
targetCategory.items.splice(targetIndex, 0, movedItem);
$scope.$applyAsync();
};
$scope.itemDefinitions = [];
$scope.loadItemDefinitions = function () {
return $http.get("/api/budget/definitions/items")
.then(res => {
console.log("Definitioner laddade:", res.data);
$scope.itemDefinitions = res.data || [];
});
};
$scope.getDefinitionName = function (item) {
if (!item || !item.budgetItemDefinitionId) return null;
const def = $scope.itemDefinitions.find(d => d.id === item.budgetItemDefinitionId);
return def?.name || null;
};
$scope.addPopupAbove = false;
$scope.openItemPopup = function ($event, category) {
const trigger = $event.currentTarget;
const rect = trigger.getBoundingClientRect();
$scope.addPopupData = {
category: category,
newItemType: "expense",
newItemDefinition: "",
newItemLabel: "",
newItemAmount: null
};
$scope.filteredDefinitions = [];
$scope.addPopupVisible = true;
// Vänta tills popup finns i DOM
setTimeout(() => {
const popup = document.querySelector('.add-item-popup');
if (!popup) return;
const popupHeight = popup.offsetHeight;
const margin = 6;
const spaceBelow = window.innerHeight - rect.bottom - margin;
const spaceAbove = rect.top - margin;
let top;
let showAbove = false;
if (spaceBelow >= popupHeight) {
top = rect.bottom + margin;
} else if (spaceAbove >= popupHeight) {
top = rect.top - popupHeight - margin;
showAbove = true;
} else {
// Får inte plats helt välj bästa plats och justera top
showAbove = spaceAbove > spaceBelow;
top = showAbove
? Math.max(0, rect.top - popupHeight - margin)
: rect.bottom + margin;
}
$scope.$apply(() => {
$scope.addPopupStyle = {
position: "fixed",
top: `${top}px`,
left: `${rect.left}px`
};
$scope.addPopupAbove = showAbove;
});
}, 0);
if (!$scope.itemDefinitions || $scope.itemDefinitions.length === 0) {
$scope.loadItemDefinitions();
}
};
$scope.addPopupVisible = false;
$scope.addPopupStyle = {};
$scope.addPopupData = {};
$scope.filteredDefinitions = [];
$scope.showDefinitionSuggestions = false;
$scope.updateDefinitionSuggestions = function () {
const term = $scope.addPopupData.newItemDefinition?.toLowerCase() || '';
console.log("Sökterm:", term);
$scope.filteredDefinitions = $scope.itemDefinitions.filter(d =>
d.Name && d.Name.toLowerCase().includes(term)
);
$scope.showDefinitionSuggestions = true;
};
$scope.selectDefinitionSuggestion = function (name) {
$scope.addPopupData.newItemDefinition = name;
$scope.filteredDefinitions = [];
$scope.showDefinitionSuggestions = false;
};
// För att inte stänga direkt vid klick
let suggestionBlurTimeout;
$scope.hideSuggestionsDelayed = function () {
suggestionBlurTimeout = setTimeout(() => {
$scope.$apply(() => {
$scope.showDefinitionSuggestions = false;
});
}, 200);
};
document.addEventListener('click', function (e) {
const popup = document.querySelector('.add-item-popup');
const isInsidePopup = popup?.contains(e.target);
const isButton = e.target.closest('.add-post-btn');
if (!isInsidePopup && !isButton) {
$scope.$apply(() => {
$scope.addPopupVisible = false;
$scope.filteredDefinitions = [];
});
}
});
$scope.addItemFromDefinition = function (cat) {
const definitionName = cat.newItemDefinition?.trim();
const label = cat.newItemLabel?.trim();
const amount = parseFloat(cat.newItemAmount);
if (!definitionName || isNaN(amount)) return;
const matched = $scope.itemDefinitions.find(d => d.name.toLowerCase() === definitionName.toLowerCase());
const isExpense = cat.newItemType === "expense";
const includeInSummary = cat.newItemType !== "saving";
const item = {
name: label || definitionName,
amount: amount,
isExpense: isExpense,
includeInSummary: includeInSummary,
budgetCategoryId: cat.id,
budgetItemDefinitionId: matched?.id || null
};
$http.post("/api/budget/item", item).then(res => {
item.id = res.data.id;
cat.items.push(item);
$scope.showToast("Post tillagd!");
cat.addingItem = false;
// Om det var ny definition hämta listan på nytt
if (!matched) $scope.loadItemDefinitions();
});
};
$scope.addItemFromPopup = function () {
const cat = $scope.addPopupData.category;
const def = $scope.addPopupData.newItemDefinition?.trim();
const label = $scope.addPopupData.newItemLabel?.trim();
const amount = parseFloat($scope.addPopupData.newItemAmount);
const type = $scope.addPopupData.newItemType;
if (!def || isNaN(amount)) return;
const matched = $scope.itemDefinitions.find(d => d.Name && d.Name.toLowerCase() === def.toLowerCase());
const item = {
name: label || def,
amount: amount,
isExpense: type === "expense",
includeInSummary: type !== "saving",
budgetCategoryId: cat.id,
budgetItemDefinitionId: matched?.Id || null
};
$http.post("/api/budget/item", item).then(res => {
item.id = res.data.id;
cat.items.push(item);
$scope.showToast("Post tillagd!");
$scope.addPopupVisible = false;
if (!matched) $scope.loadItemDefinitions();
});
};
$scope.isExpenseCategory = function (cat) {
return cat.items.some(i => i.isExpense);
};
$scope.drawCategoryChart = function () {
const ctx = document.getElementById("expenseChart");
if (!ctx || !$scope.budget?.categories) return;
const labels = [];
const data = [];
const colors = [];
$scope.budget.categories.forEach(cat => {
const sum = cat.items
.filter(i => i.isExpense)
.reduce((acc, i) => acc + i.amount, 0);
if (sum > 0) {
labels.push(cat.name);
data.push(sum);
colors.push(cat.color || "#94a3b8");
}
});
const total = data.reduce((a, b) => a + b, 0);
const topIndex = data.indexOf(Math.max(...data));
$scope.topExpenseCategory = topIndex !== -1 ? {
name: labels[topIndex],
value: data[topIndex],
percent: (data[topIndex] / total) * 100
} : null;
if (window.expenseChart && typeof window.expenseChart.destroy === 'function') {
window.expenseChart.destroy();
}
window.expenseChart = new Chart(ctx, {
type: 'pie',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => `${ctx.label}: ${ctx.formattedValue} kr`
}
}
}
}
});
};
$scope.loadItemDefinitions().then(() => {
$scope.loadBudget();
});
});