dev #7
@ -1,90 +1,101 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from .models import Expense, Category, Income, Tag, Account, FuelEntry
|
from .models import Expense, Category, Income, Tag, Account, FuelEntry, Goal
|
||||||
|
|
||||||
|
|
||||||
class ExpenseForm(forms.ModelForm):
|
class ExpenseForm(forms.ModelForm):
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
# Get user
|
# Get user
|
||||||
user = kwargs.pop('user', None)
|
user = kwargs.pop("user", None)
|
||||||
|
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
self.fields['category'].queryset = Category.objects.filter(owner=user)
|
self.fields["category"].queryset = Category.objects.filter(owner=user)
|
||||||
self.fields['tags'].queryset = Tag.objects.filter(owner=user)
|
self.fields["tags"].queryset = Tag.objects.filter(owner=user)
|
||||||
self.fields['account'].queryset = Account.objects.filter(owner=user, active=True)
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
self.fields['date'].input_formats = ['%Y-%m-%d']
|
owner=user, active=True
|
||||||
|
)
|
||||||
|
self.fields["date"].input_formats = ["%Y-%m-%d"]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Expense
|
model = Expense
|
||||||
fields = [
|
fields = [
|
||||||
'date',
|
"date",
|
||||||
'amount',
|
"amount",
|
||||||
'description',
|
"description",
|
||||||
'category',
|
"category",
|
||||||
'account',
|
"account",
|
||||||
'tags',
|
"tags",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'date': forms.DateInput(
|
"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"}),
|
||||||
format='%Y-%m-%d',
|
"widget": forms.CheckboxSelectMultiple(),
|
||||||
attrs={'type': 'date'}),
|
|
||||||
'widget': forms.CheckboxSelectMultiple(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TagForm(forms.ModelForm):
|
class TagForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Tag
|
model = Tag
|
||||||
fields = ['name']
|
fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
class AccountForm(forms.ModelForm):
|
class AccountForm(forms.ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Account
|
model = Account
|
||||||
fields = ['name', 'initial_balance', 'active']
|
fields = ["name", "initial_balance", "active"]
|
||||||
|
|
||||||
|
|
||||||
class IncomeForm(forms.ModelForm):
|
class IncomeForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Income
|
model = Income
|
||||||
fields = ['account', 'name', 'amount', 'date']
|
fields = ["account", "name", "amount", "date"]
|
||||||
widgets = {
|
widgets = {"date": forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})}
|
||||||
'date': forms.DateInput(
|
|
||||||
format='%Y-%m-%d',
|
|
||||||
attrs={'type': 'date'})
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop('user')
|
user = kwargs.pop("user")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['account'].queryset = Account.objects.filter(
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
owner=user,
|
owner=user,
|
||||||
active=True,
|
active=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class FuelEntryForm(forms.Form):
|
class FuelEntryForm(forms.Form):
|
||||||
# Expense fields
|
# Expense fields
|
||||||
date = forms.DateField(widget=forms.DateInput(
|
date = forms.DateField(
|
||||||
format='%Y-%m-%d',
|
widget=forms.DateInput(format="%Y-%m-%d", attrs={"type": "date"})
|
||||||
attrs={'type': 'date'}))
|
)
|
||||||
amount = forms.DecimalField(max_digits=10, decimal_places=2)
|
amount = forms.DecimalField(max_digits=10, decimal_places=2)
|
||||||
account = forms.ModelChoiceField(queryset=None)
|
account = forms.ModelChoiceField(queryset=None)
|
||||||
|
|
||||||
# Specifics fuel fields
|
# Specifics fuel fields
|
||||||
odometer = forms.DecimalField(label='Current kilometers')
|
odometer = forms.DecimalField(label="Current kilometers")
|
||||||
liters = forms.DecimalField(max_digits=8, decimal_places=2)
|
liters = forms.DecimalField(max_digits=8, decimal_places=2)
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
user = kwargs.pop('user')
|
user = kwargs.pop("user")
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
if user:
|
if user:
|
||||||
self.fields['account'].queryset = (
|
self.fields["account"].queryset = user.accounts.filter(active=True)
|
||||||
user.accounts.filter(active=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
class CategoryForm(forms.ModelForm):
|
class CategoryForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = ['name', 'parent']
|
fields = ["name", "parent"]
|
||||||
|
|
||||||
|
class GoalForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Goal
|
||||||
|
fields = ['name', 'target_amount', 'category', "show_on_home"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
user = kwargs.pop('user')
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.fields['category'].queryset = (
|
||||||
|
user.categories.all()
|
||||||
|
)
|
||||||
27
expenses_manager/expenses/migrations/0008_goal.py
Normal file
27
expenses_manager/expenses/migrations/0008_goal.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-05-05 09:08
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0007_alter_category_options_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Goal',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('target_amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='expenses.category')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.10 on 2026-05-05 13:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0008_goal'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='goal',
|
||||||
|
name='show_on_home',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -7,141 +7,127 @@ from django.db.models.fields import related
|
|||||||
from django.db.models.functions import ExtractMonth
|
from django.db.models.functions import ExtractMonth
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
|
||||||
class Category(models.Model):
|
class Category(models.Model):
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
slug = models.SlugField(blank=True)
|
slug = models.SlugField(blank=True)
|
||||||
|
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="categories",
|
related_name="categories",
|
||||||
)
|
)
|
||||||
|
|
||||||
parent = models.ForeignKey(
|
parent = models.ForeignKey(
|
||||||
'self',
|
"self",
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="children",
|
related_name="children",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("name", "parent", "owner", "slug")
|
unique_together = ("name", "parent", "owner", "slug")
|
||||||
verbose_name_plural = "categories"
|
verbose_name_plural = "categories"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Account(models.Model):
|
|
||||||
|
class Account(models.Model):
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="accounts",
|
related_name="accounts",
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
initial_balance = models.DecimalField(
|
initial_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||||
max_digits=12,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0
|
|
||||||
)
|
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ["name"]
|
||||||
|
|
||||||
def current_balance(self):
|
def current_balance(self):
|
||||||
expenses_total = (
|
expenses_total = self.expenses.aggregate(total=Sum("amount"))[
|
||||||
self.expenses.aggregate(total=Sum('amount'))['total']
|
"total"
|
||||||
or Decimal('0')
|
] or Decimal("0")
|
||||||
)
|
income_total = self.incomes.aggregate(total=Sum("amount"))["total"] or Decimal(
|
||||||
income_total = (
|
"0"
|
||||||
self.incomes.aggregate(total=Sum('amount'))['total']
|
|
||||||
or Decimal('0')
|
|
||||||
)
|
)
|
||||||
return self.initial_balance + income_total - expenses_total
|
return self.initial_balance + income_total - expenses_total
|
||||||
|
|
||||||
def monthly_balance(self, year=None):
|
def monthly_balance(self, year=None):
|
||||||
year = year or date.today().year
|
year = year or date.today().year
|
||||||
|
|
||||||
incomes = (
|
incomes = (
|
||||||
self.incomes
|
self.incomes.filter(date__year=year)
|
||||||
.filter(date__year=year)
|
.annotate(month=ExtractMonth("date"))
|
||||||
.annotate(month=ExtractMonth('date'))
|
.values("month")
|
||||||
.values('month')
|
.annotate(total=Sum("amount"))
|
||||||
.annotate(total=Sum('amount'))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expenses = (
|
expenses = (
|
||||||
self.expenses
|
self.expenses.filter(date__year=year)
|
||||||
.filter(date__year=year)
|
.annotate(month=ExtractMonth("date"))
|
||||||
.annotate(month=ExtractMonth('date'))
|
.values("month")
|
||||||
.values('month')
|
.annotate(total=Sum("amount"))
|
||||||
.annotate(total=Sum('amount'))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
income_map = {i['month']: i['total'] for i in incomes}
|
income_map = {i["month"]: i["total"] for i in incomes}
|
||||||
expenses_map = {e['month']: e['total'] for e in expenses}
|
expenses_map = {e["month"]: e["total"] for e in expenses}
|
||||||
|
|
||||||
balance = self.initial_balance
|
balance = self.initial_balance
|
||||||
data = []
|
data = []
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
balance += income_map.get(month, Decimal('0'))
|
balance += income_map.get(month, Decimal("0"))
|
||||||
balance -= expenses_map.get(month, Decimal('0'))
|
balance -= expenses_map.get(month, Decimal("0"))
|
||||||
|
|
||||||
data.append({
|
data.append(
|
||||||
'month': month,
|
{
|
||||||
'balance': float(balance),
|
"month": month,
|
||||||
})
|
"balance": float(balance),
|
||||||
|
}
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def balance_until(self, date):
|
def balance_until(self, date):
|
||||||
incomes_total = (
|
incomes_total = self.incomes.filter(date__lte=date).aggregate(
|
||||||
self.incomes
|
total=Sum("amount")
|
||||||
.filter(date__lte=date)
|
)["total"] or Decimal("0")
|
||||||
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
|
||||||
)
|
expenses_total = self.expenses.filter(date__lte=date).aggregate(
|
||||||
|
total=Sum("amount")
|
||||||
expenses_total = (
|
)["total"] or Decimal("0")
|
||||||
self.expenses
|
|
||||||
.filter(date__lte=date)
|
|
||||||
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.initial_balance + incomes_total - expenses_total
|
return self.initial_balance + incomes_total - expenses_total
|
||||||
|
|
||||||
def monthly_net(self, year=None):
|
def monthly_net(self, year=None):
|
||||||
year = year or date.today().year
|
year = year or date.today().year
|
||||||
data = []
|
data = []
|
||||||
|
|
||||||
for month in range(1, 13):
|
for month in range(1, 13):
|
||||||
income = (
|
income = self.incomes.filter(date__year=year, date__month=month).aggregate(
|
||||||
self.incomes
|
total=Sum("amount")
|
||||||
.filter(date__year=year, date__month=month)
|
)["total"] or Decimal("0")
|
||||||
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
expense = self.expenses.filter(
|
||||||
)
|
date__year=year, date__month=month
|
||||||
expense = (
|
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
|
||||||
self.expenses
|
|
||||||
.filter(date__year=year, date__month=month)
|
data.append({"month": month, "net": float(income - expense)})
|
||||||
.aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
|
||||||
)
|
|
||||||
|
|
||||||
data.append({
|
|
||||||
'month': month,
|
|
||||||
'net': float(income - expense)
|
|
||||||
})
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@ -153,108 +139,145 @@ class Tag(models.Model):
|
|||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="tags",
|
related_name="tags",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("name", "owner")
|
unique_together = ("name", "owner")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class Expense(models.Model):
|
class Expense(models.Model):
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
description = models.TextField(blank=True)
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL,
|
settings.AUTH_USER_MODEL,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="expenses",
|
related_name="expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
category = models.ForeignKey(
|
category = models.ForeignKey(
|
||||||
Category,
|
Category,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="expenses",
|
related_name="expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
account = models.ForeignKey(
|
account = models.ForeignKey(
|
||||||
Account,
|
Account,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
related_name="expenses",
|
related_name="expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
tags = models.ManyToManyField(
|
tags = models.ManyToManyField(
|
||||||
Tag,
|
Tag,
|
||||||
blank=True,
|
blank=True,
|
||||||
related_name="expenses",
|
related_name="expenses",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = models.DateField(auto_now_add=True)
|
created_at = models.DateField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-date"]
|
ordering = ["-date"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{} - {}".format(self.date, self.amount)
|
return "{} - {}".format(self.date, self.amount)
|
||||||
|
|
||||||
|
|
||||||
class Income(models.Model):
|
class Income(models.Model):
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.CASCADE
|
|
||||||
)
|
|
||||||
account = models.ForeignKey(
|
account = models.ForeignKey(
|
||||||
Account,
|
Account, on_delete=models.CASCADE, related_name="incomes"
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='incomes'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(max_length=150)
|
name = models.CharField(max_length=150)
|
||||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
date = models.DateField()
|
date = models.DateField()
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-date']
|
ordering = ["-date"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'{self.name} - {self.amount}'
|
return f"{self.name} - {self.amount}"
|
||||||
|
|
||||||
|
|
||||||
class FuelEntry(models.Model):
|
class FuelEntry(models.Model):
|
||||||
expense = models.OneToOneField(
|
expense = models.OneToOneField(
|
||||||
Expense,
|
Expense, on_delete=models.CASCADE, related_name="fuel_data"
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name="fuel_data"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
odometer = models.PositiveIntegerField() # kilometers
|
odometer = models.PositiveIntegerField() # kilometers
|
||||||
liters = models.DecimalField(max_digits=8, decimal_places=2)
|
liters = models.DecimalField(max_digits=8, decimal_places=2)
|
||||||
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['odometer']
|
ordering = ["odometer"]
|
||||||
|
|
||||||
def km_since_previous(self):
|
def km_since_previous(self):
|
||||||
previous = FuelEntry.objects.filter(
|
previous = (
|
||||||
expense__owner=self.expense.owner,
|
FuelEntry.objects.filter(
|
||||||
odometer__lt=self.odometer
|
expense__owner=self.expense.owner, odometer__lt=self.odometer
|
||||||
).order_by('-odometer').first()
|
)
|
||||||
|
.order_by("-odometer")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if previous:
|
if previous:
|
||||||
return self.odometer - previous.odometer
|
return self.odometer - previous.odometer
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def price_per_liter(self):
|
def price_per_liter(self):
|
||||||
if self.liters:
|
if self.liters:
|
||||||
return self.expense.amount / self.liters
|
return self.expense.amount / self.liters
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def consumption(self):
|
def consumption(self):
|
||||||
km = self.km_since_previous()
|
km = self.km_since_previous()
|
||||||
if km and km > 0:
|
if km and km > 0:
|
||||||
return (self.liters / km) * 100
|
return (self.liters / km) * 100
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Goal(models.Model):
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
target_amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
category = models.ForeignKey("Category", on_delete=models.CASCADE)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
show_on_home = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def progress(self):
|
||||||
|
"""
|
||||||
|
Calculate the accumulated spending for the goal category.
|
||||||
|
This method returns the sum of all expenses of the owner in the goal's category.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: Total amount spent in the goal category, or Decimal('0') when there is no spending.
|
||||||
|
|
||||||
|
"""
|
||||||
|
total = Expense.objects.filter(
|
||||||
|
owner=self.owner,
|
||||||
|
category=self.category,
|
||||||
|
).aggregate(total=Sum("amount"))["total"]
|
||||||
|
|
||||||
|
return total or 0
|
||||||
|
|
||||||
|
def percentage(self):
|
||||||
|
"""
|
||||||
|
Calculate the completion percentage of the goal.
|
||||||
|
This method returns how much of the target amount has been reached as a percentage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: Percentage of the goal that has been reached, or Decimal('0') when the target amount is zero.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self.target_amount == 0:
|
||||||
|
return 0
|
||||||
|
return (self.progress() / self.target_amount) * 100
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|||||||
@ -80,7 +80,7 @@ a.danger {
|
|||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 20px
|
gap: 20px
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,15 +89,14 @@ a.danger {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-chart {
|
.card-chart {
|
||||||
max-width: 300px;
|
height: 500px;
|
||||||
}
|
position: relative;
|
||||||
|
|
||||||
.card-chart canvas {
|
|
||||||
width: 100% !important;
|
|
||||||
height: 220px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
@ -105,6 +104,19 @@ a.danger {
|
|||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-chart canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
.kpi-grid {
|
.kpi-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
@ -310,4 +322,96 @@ tbody tr:hover {
|
|||||||
|
|
||||||
.tag:hover {
|
.tag:hover {
|
||||||
background: #dbe3ee;
|
background: #dbe3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown.active {
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 2px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle {
|
||||||
|
padding: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 150px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a {
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu a:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown:hover .dropdown-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
min-width: 50px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.low {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.medium {
|
||||||
|
background: #f1c40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.high {
|
||||||
|
background: #2ecc71;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goals-widget {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.goal-card {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9f9f9;
|
||||||
}
|
}
|
||||||
@ -18,9 +18,18 @@
|
|||||||
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
|
<a href="{% url 'expense_list' %}" class="nav-item {% if active_menu == 'expenses' %}active{% endif %} ">Gastos</a>
|
||||||
<a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a>
|
<a href="{% url 'account_list' %}" class="nav-item {% if active_menu == 'accounts' %}active{% endif %} ">Cuentas</a>
|
||||||
<a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a>
|
<a href="{% url 'income_list' %}" class="nav-item {% if active_menu == 'incomes' %}active{% endif %} ">Ingresos</a>
|
||||||
<a href="{% url 'tag_list' %}" class="nav-item {% if active_menu == 'tags' %}active{% endif %} ">Etiquetas</a>
|
<!-- <a href="{% url 'tag_list' %}" class="nav-item {% if active_menu == 'tags' %}active{% endif %} ">Etiquetas</a> -->
|
||||||
<a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a>
|
<a href="{% url 'fuel_list' %}" class="nav-item {% if active_menu == 'fuel' %}active{% endif %} ">Repostajes</a>
|
||||||
<a href="{% url 'category_list' %}" class="nav-item {% if active_menu == 'categories' %}active{% endif %} ">Categorías</a>
|
<!-- <a href="{% url 'category_list' %}" class="nav-item {% if active_menu == 'categories' %}active{% endif %} ">Categorías</a> -->
|
||||||
|
<div class="nav-item dropdown {% if active_menu == 'settings' %}active{% endif %}">
|
||||||
|
<span class="dropdown-toggle">Configuraciones ▼</span>
|
||||||
|
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a href="{% url 'category_list' %}">Categorías</a>
|
||||||
|
<a href="{% url 'tag_list' %}">Etiquetas</a>
|
||||||
|
<a href="{% url 'goal_list' %}">Objetivos</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% load l10n %}
|
||||||
|
|
||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -147,8 +149,10 @@
|
|||||||
<div class="card card-chart">
|
<div class="card card-chart">
|
||||||
<h3>{{ acc.name }}</h3>
|
<h3>{{ acc.name }}</h3>
|
||||||
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }} €</p>
|
<p><strong>Saldo actual:</strong> {{ acc.current_balance|floatformat:2 }} €</p>
|
||||||
|
|
||||||
<canvas id="accountChart{{ acc.id }}"></canvas>
|
<div class="canvas-wrapper">
|
||||||
|
<canvas id="accountChart{{ acc.id }}"></canvas>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@ -214,6 +218,39 @@
|
|||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ========================= -->
|
||||||
|
<!-- Goals -->
|
||||||
|
<!-- ========================= -->
|
||||||
|
|
||||||
|
<h3>Objetivos</h3>
|
||||||
|
{% if goals %}
|
||||||
|
<div class="goals-widget">
|
||||||
|
{% for goal in goals %}
|
||||||
|
<div class="goal-card">
|
||||||
|
<strong>{{ goal.name }}</strong>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill
|
||||||
|
{% if goal.percentage < 50 %} low
|
||||||
|
{% elif goal.percentage < 80 %} medium
|
||||||
|
{% else %} high
|
||||||
|
{% endif %}"
|
||||||
|
style="width: {{ goal.percentage|unlocalize }}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No hay objetivos.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ========================= -->
|
||||||
|
<!-- Categories -->
|
||||||
|
<!-- ========================= -->
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
{% extends "expenses/base.html" %}
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% load l10n %}
|
||||||
|
|
||||||
{% block title %}Home{% endblock %}
|
{% block title %}Home{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -60,4 +62,30 @@
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<h3>Objetivos</h3>
|
||||||
|
{% if goals %}
|
||||||
|
<div class="goals-widget">
|
||||||
|
{% for goal in goals %}
|
||||||
|
<div class="goal-card">
|
||||||
|
<strong>{{ goal.name }}</strong>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill
|
||||||
|
{% if goal.percentage < 50 %} low
|
||||||
|
{% elif goal.percentage < 80 %} medium
|
||||||
|
{% else %} high
|
||||||
|
{% endif %}"
|
||||||
|
style="width: {{ goal.percentage|unlocalize }}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€ ({{ goal.percentage|floatformat:1 }}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No tienes objetivos aún.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
20
expenses_manager/expenses/templates/goals/form.html
Normal file
20
expenses_manager/expenses/templates/goals/form.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{% extends "expenses/base.html" %}
|
||||||
|
{% block title %}
|
||||||
|
Nuevo objetivo
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>
|
||||||
|
Nuevo objetivo
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.as_p }}
|
||||||
|
<button type="submit">
|
||||||
|
Guardar
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a class="btn secondary" href="{% url 'goal_list' %}">Volver</a>
|
||||||
|
{% endblock %}
|
||||||
51
expenses_manager/expenses/templates/goals/list.html
Normal file
51
expenses_manager/expenses/templates/goals/list.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{% extends "expenses/base.html" %}
|
||||||
|
|
||||||
|
{% load l10n %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
Objetivos
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Objetivos</h2>
|
||||||
|
|
||||||
|
<a class=btn href="{% url 'goal_create' %}">➕ Nuevo objetivo</a>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Progreso</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{% for goal in goals %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ goal.name }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="progress-container">
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ goal.progress|floatformat:1 }}€ / {{ goal.target_amount|floatformat:1 }}€
|
||||||
|
</span>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill
|
||||||
|
{% if goal.percentage < 50 %} low
|
||||||
|
{% elif goal.percentage < 80 %} medium
|
||||||
|
{% else %} high
|
||||||
|
{% endif %}"
|
||||||
|
style="width: {{ goal.percentage|unlocalize }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-label">
|
||||||
|
{{ goal.percentage|floatformat:1}}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'goal_edit' goal.id %}">Editar</a>
|
||||||
|
<a href="{% url 'goal_delete' goal.id %}" class="danger">Eliminar</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -24,4 +24,9 @@ urlpatterns = [
|
|||||||
path('fuel/create/', views.fuel_create, name='fuel_create'),
|
path('fuel/create/', views.fuel_create, name='fuel_create'),
|
||||||
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
|
path('fuel/<int:pk>/edit/', views.fuel_edit, name='fuel_edit'),
|
||||||
path('categories/', views.category_list, name='category_list'),
|
path('categories/', views.category_list, name='category_list'),
|
||||||
|
path('settings/', views.settings_index, name='settings_index'),
|
||||||
|
path('goals/', views.goal_list, name='goal_list'),
|
||||||
|
path('goals/new/', views.goal_create, name='goal_create'),
|
||||||
|
path('goals/<int:pk>/edit/', views.goal_edit, name='goal_edit'),
|
||||||
|
path('goals/<int:pk>/delete/', views.goal_delete, name='goal_delete'),
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user