Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0789752dc6 | ||
|
|
22e473eb14 |
Binary file not shown.
Binary file not shown.
@ -37,17 +37,11 @@ CSRF_TRUSTED_ORIGINS = [
|
|||||||
for host in CSRF_TRUSTED_ORIGINS
|
for host in CSRF_TRUSTED_ORIGINS
|
||||||
]
|
]
|
||||||
|
|
||||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
SESSION_COOKIE_SAMESITE = "None"
|
SESSION_COOKIE_SAMESITE = "None"
|
||||||
CSRF_COOKIE_SAMESITE = "None"
|
CSRF_COOKIE_SAMESITE = "None"
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
'django.contrib.auth',
|
'django.contrib.auth',
|
||||||
@ -65,8 +59,6 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
|
||||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||||
@ -83,7 +75,6 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
|
||||||
'core.context_processors.project_context',
|
'core.context_processors.project_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -92,10 +83,6 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = 'config.wsgi.application'
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.mysql',
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
@ -110,10 +97,6 @@ DATABASES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
@ -129,33 +112,22 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / 'static',
|
BASE_DIR / 'static',
|
||||||
BASE_DIR / 'assets',
|
BASE_DIR / 'assets',
|
||||||
BASE_DIR / 'node_modules',
|
BASE_DIR / 'node_modules',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Email
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
EMAIL_BACKEND = os.getenv(
|
EMAIL_BACKEND = os.getenv(
|
||||||
"EMAIL_BACKEND",
|
"EMAIL_BACKEND",
|
||||||
"django.core.mail.backends.smtp.EmailBackend"
|
"django.core.mail.backends.smtp.EmailBackend"
|
||||||
@ -173,10 +145,16 @@ CONTACT_EMAIL_TO = [
|
|||||||
if item.strip()
|
if item.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
|
||||||
if EMAIL_USE_SSL:
|
if EMAIL_USE_SSL:
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
# Default primary key field type
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'home'
|
||||||
|
LOGOUT_REDIRECT_URL = 'home'
|
||||||
|
|
||||||
|
JWT_ACCESS_TOKEN_MINUTES = int(os.getenv('JWT_ACCESS_TOKEN_MINUTES', '30'))
|
||||||
|
JWT_REFRESH_TOKEN_DAYS = int(os.getenv('JWT_REFRESH_TOKEN_DAYS', '30'))
|
||||||
|
JWT_ISSUER = os.getenv('JWT_ISSUER', 'momoledger')
|
||||||
|
JWT_AUDIENCE = os.getenv('JWT_AUDIENCE', 'momoledger-mobile')
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|||||||
@ -1,23 +1,7 @@
|
|||||||
"""
|
|
||||||
URL configuration for config project.
|
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import include, path
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
@ -25,5 +9,6 @@ urlpatterns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|||||||
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/jwt_auth.cpython-311.pyc
Normal file
BIN
core/__pycache__/jwt_auth.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,17 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from .models import BusinessProfile, Transaction
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BusinessProfile)
|
||||||
|
class BusinessProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('business_name', 'user', 'opening_ecash', 'opening_physical_cash', 'current_ecash', 'current_physical_cash')
|
||||||
|
search_fields = ('business_name', 'user__username', 'user__email')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Transaction)
|
||||||
|
class TransactionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('client_name', 'transaction_type', 'amount', 'service_charge', 'business', 'created_at')
|
||||||
|
list_filter = ('transaction_type', 'created_at')
|
||||||
|
search_fields = ('client_name', 'business__business_name', 'created_by__username')
|
||||||
|
date_hierarchy = 'created_at'
|
||||||
|
|||||||
165
core/forms.py
Normal file
165
core/forms.py
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import BusinessProfile, Transaction
|
||||||
|
|
||||||
|
|
||||||
|
INPUT_CLASS = 'form-control form-control-lg momo-input'
|
||||||
|
SELECT_CLASS = 'form-select form-select-lg momo-input'
|
||||||
|
|
||||||
|
|
||||||
|
class SignUpForm(UserCreationForm):
|
||||||
|
first_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'First name'}))
|
||||||
|
last_name = forms.CharField(max_length=150, required=True, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Last name'}))
|
||||||
|
email = forms.EmailField(required=True, widget=forms.EmailInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Email address'}))
|
||||||
|
|
||||||
|
class Meta(UserCreationForm.Meta):
|
||||||
|
model = User
|
||||||
|
fields = ('first_name', 'last_name', 'username', 'email')
|
||||||
|
widgets = {
|
||||||
|
'username': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['username'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Username'})
|
||||||
|
self.fields['password1'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Password'})
|
||||||
|
self.fields['password2'].widget.attrs.update({'class': INPUT_CLASS, 'placeholder': 'Confirm password'})
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
user.first_name = self.cleaned_data['first_name']
|
||||||
|
user.last_name = self.cleaned_data['last_name']
|
||||||
|
user.email = self.cleaned_data['email']
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(AuthenticationForm):
|
||||||
|
username = forms.CharField(widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Username'}))
|
||||||
|
password = forms.CharField(widget=forms.PasswordInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Password'}))
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessProfileForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = BusinessProfile
|
||||||
|
fields = ['business_name', 'logo', 'opening_ecash', 'opening_physical_cash']
|
||||||
|
widgets = {
|
||||||
|
'business_name': forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'e.g. BrightPay MoMo Point'}),
|
||||||
|
'logo': forms.ClearableFileInput(attrs={'class': 'form-control momo-file', 'accept': 'image/*'}),
|
||||||
|
'opening_ecash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
|
||||||
|
'opening_physical_cash': forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0'}),
|
||||||
|
}
|
||||||
|
help_texts = {
|
||||||
|
'opening_ecash': 'Used as your starting e-cash wallet for the first setup.',
|
||||||
|
'opening_physical_cash': 'Used as your starting notes/coins balance for the first setup.',
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
profile = super().save(commit=False)
|
||||||
|
should_sync = not profile.pk or not profile.transactions.exists()
|
||||||
|
if should_sync:
|
||||||
|
profile.sync_current_to_opening()
|
||||||
|
if commit:
|
||||||
|
profile.save()
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionForm(forms.Form):
|
||||||
|
client_name = forms.CharField(max_length=120, widget=forms.TextInput(attrs={'class': INPUT_CLASS, 'placeholder': 'Client name'}))
|
||||||
|
amount = forms.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('0.01'), widget=forms.NumberInput(attrs={'class': INPUT_CLASS, 'step': '0.01', 'min': '0.01'}))
|
||||||
|
transaction_type = forms.ChoiceField(choices=Transaction.TYPE_CHOICES, widget=forms.Select(attrs={'class': SELECT_CLASS}))
|
||||||
|
notes = forms.CharField(required=False, widget=forms.Textarea(attrs={'class': 'form-control momo-input', 'rows': 3, 'placeholder': 'Optional note about the transaction'}))
|
||||||
|
|
||||||
|
def __init__(self, *args, business=None, instance=None, **kwargs):
|
||||||
|
self.business = business
|
||||||
|
self.instance = instance
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
transaction_type = cleaned_data.get('transaction_type')
|
||||||
|
amount = cleaned_data.get('amount')
|
||||||
|
if not self.business or not transaction_type or amount is None:
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
if self.instance is not None:
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
ecash_delta, physical_delta, _ = Transaction.calculate_effect(transaction_type, amount)
|
||||||
|
if self.business.current_ecash + ecash_delta < 0:
|
||||||
|
self.add_error('amount', 'Not enough e-cash for this transaction.')
|
||||||
|
if self.business.current_physical_cash + physical_delta < 0:
|
||||||
|
self.add_error('amount', 'Not enough physical cash for this transaction.')
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def save(self, user):
|
||||||
|
if not self.business:
|
||||||
|
raise ValidationError('Business profile is required.')
|
||||||
|
if self.instance is not None:
|
||||||
|
return self.instance.update_logged_transaction(
|
||||||
|
client_name=self.cleaned_data['client_name'],
|
||||||
|
amount=self.cleaned_data['amount'],
|
||||||
|
transaction_type=self.cleaned_data['transaction_type'],
|
||||||
|
notes=self.cleaned_data['notes'],
|
||||||
|
)
|
||||||
|
return Transaction.create_logged_transaction(
|
||||||
|
business=self.business,
|
||||||
|
user=user,
|
||||||
|
client_name=self.cleaned_data['client_name'],
|
||||||
|
amount=self.cleaned_data['amount'],
|
||||||
|
transaction_type=self.cleaned_data['transaction_type'],
|
||||||
|
notes=self.cleaned_data['notes'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportFilterForm(forms.Form):
|
||||||
|
PERIOD_CHOICES = [
|
||||||
|
('daily', 'Daily'),
|
||||||
|
('weekly', 'Weekly'),
|
||||||
|
('monthly', 'Monthly'),
|
||||||
|
('yearly', 'Yearly'),
|
||||||
|
('custom', 'Custom range'),
|
||||||
|
]
|
||||||
|
|
||||||
|
period = forms.ChoiceField(choices=PERIOD_CHOICES, initial='daily', widget=forms.Select(attrs={'class': 'form-select momo-input'}))
|
||||||
|
start_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
|
||||||
|
end_date = forms.DateField(required=False, widget=forms.DateInput(attrs={'class': 'form-control momo-input', 'type': 'date'}))
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
period = cleaned_data.get('period')
|
||||||
|
start_date = cleaned_data.get('start_date')
|
||||||
|
end_date = cleaned_data.get('end_date')
|
||||||
|
if period == 'custom':
|
||||||
|
if not start_date or not end_date:
|
||||||
|
raise forms.ValidationError('Choose both a start and end date for a custom report.')
|
||||||
|
if end_date < start_date:
|
||||||
|
raise forms.ValidationError('End date cannot be before start date.')
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
def get_range(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
period = self.cleaned_data.get('period') or 'daily'
|
||||||
|
if period == 'daily':
|
||||||
|
return today, today
|
||||||
|
if period == 'weekly':
|
||||||
|
start = today - timezone.timedelta(days=today.weekday())
|
||||||
|
return start, start + timezone.timedelta(days=6)
|
||||||
|
if period == 'monthly':
|
||||||
|
start = today.replace(day=1)
|
||||||
|
if start.month == 12:
|
||||||
|
next_month = start.replace(year=start.year + 1, month=1, day=1)
|
||||||
|
else:
|
||||||
|
next_month = start.replace(month=start.month + 1, day=1)
|
||||||
|
return start, next_month - timezone.timedelta(days=1)
|
||||||
|
if period == 'yearly':
|
||||||
|
start = today.replace(month=1, day=1)
|
||||||
|
return start, today.replace(month=12, day=31)
|
||||||
|
return self.cleaned_data['start_date'], self.cleaned_data['end_date']
|
||||||
112
core/jwt_auth.py
Normal file
112
core/jwt_auth.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import BusinessProfile
|
||||||
|
|
||||||
|
|
||||||
|
class JWTAuthError(Exception):
|
||||||
|
def __init__(self, message: str, *, code: str = 'token_invalid', status_code: int = 401):
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.code = code
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp(value):
|
||||||
|
return int(value.timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(user: User, profile: BusinessProfile, *, token_type: str, expires_delta):
|
||||||
|
now = timezone.now()
|
||||||
|
return {
|
||||||
|
'sub': str(user.pk),
|
||||||
|
'username': user.username,
|
||||||
|
'type': token_type,
|
||||||
|
'token_version': profile.mobile_token_version,
|
||||||
|
'iat': _timestamp(now),
|
||||||
|
'nbf': _timestamp(now),
|
||||||
|
'exp': _timestamp(now + expires_delta),
|
||||||
|
'iss': settings.JWT_ISSUER,
|
||||||
|
'aud': settings.JWT_AUDIENCE,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def issue_token_pair(user: User):
|
||||||
|
profile, _ = BusinessProfile.objects.get_or_create(user=user)
|
||||||
|
access_lifetime = timedelta(minutes=settings.JWT_ACCESS_TOKEN_MINUTES)
|
||||||
|
refresh_lifetime = timedelta(days=settings.JWT_REFRESH_TOKEN_DAYS)
|
||||||
|
access_payload = _base_payload(user, profile, token_type='access', expires_delta=access_lifetime)
|
||||||
|
refresh_payload = _base_payload(user, profile, token_type='refresh', expires_delta=refresh_lifetime)
|
||||||
|
access_token = jwt.encode(access_payload, settings.SECRET_KEY, algorithm='HS256')
|
||||||
|
refresh_token = jwt.encode(refresh_payload, settings.SECRET_KEY, algorithm='HS256')
|
||||||
|
return {
|
||||||
|
'access': access_token,
|
||||||
|
'refresh': refresh_token,
|
||||||
|
'token_type': 'Bearer',
|
||||||
|
'access_expires_in': int(access_lifetime.total_seconds()),
|
||||||
|
'refresh_expires_in': int(refresh_lifetime.total_seconds()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str, *, expected_type: str):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.SECRET_KEY,
|
||||||
|
algorithms=['HS256'],
|
||||||
|
audience=settings.JWT_AUDIENCE,
|
||||||
|
issuer=settings.JWT_ISSUER,
|
||||||
|
options={'require': ['exp', 'iat', 'nbf', 'sub', 'type', 'token_version']},
|
||||||
|
)
|
||||||
|
except jwt.ExpiredSignatureError as exc:
|
||||||
|
raise JWTAuthError('Token expired. Please log in again.', code='token_expired') from exc
|
||||||
|
except jwt.InvalidTokenError as exc:
|
||||||
|
raise JWTAuthError('Invalid token.', code='token_invalid') from exc
|
||||||
|
|
||||||
|
token_type = payload.get('type')
|
||||||
|
if token_type != expected_type:
|
||||||
|
raise JWTAuthError(f'Expected a {expected_type} token.', code='token_type_invalid')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_from_payload(payload):
|
||||||
|
user = User.objects.filter(pk=payload.get('sub'), is_active=True).first()
|
||||||
|
if user is None:
|
||||||
|
raise JWTAuthError('User account not found.', code='user_not_found')
|
||||||
|
|
||||||
|
profile, _ = BusinessProfile.objects.get_or_create(user=user)
|
||||||
|
if profile.mobile_token_version != payload.get('token_version'):
|
||||||
|
raise JWTAuthError('Token is no longer valid. Please log in again.', code='token_revoked')
|
||||||
|
|
||||||
|
return user, profile
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_authorization_header(header_value: str):
|
||||||
|
if not header_value:
|
||||||
|
raise JWTAuthError('Authentication required.', code='auth_required')
|
||||||
|
|
||||||
|
scheme, _, token = header_value.partition(' ')
|
||||||
|
if scheme.lower() != 'bearer' or not token.strip():
|
||||||
|
raise JWTAuthError('Use Authorization: Bearer <token>.', code='auth_header_invalid')
|
||||||
|
|
||||||
|
payload = decode_token(token.strip(), expected_type='access')
|
||||||
|
return get_user_from_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_refresh_token(refresh_token: str):
|
||||||
|
if not refresh_token:
|
||||||
|
raise JWTAuthError('Refresh token is required.', code='refresh_required')
|
||||||
|
|
||||||
|
payload = decode_token(refresh_token.strip(), expected_type='refresh')
|
||||||
|
return get_user_from_payload(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_user_tokens(user: User):
|
||||||
|
profile, _ = BusinessProfile.objects.get_or_create(user=user)
|
||||||
|
profile.mobile_token_version += 1
|
||||||
|
profile.save(update_fields=['mobile_token_version', 'updated_at'])
|
||||||
|
return profile
|
||||||
57
core/migrations/0001_initial.py
Normal file
57
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-04-17 02:21
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BusinessProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('business_name', models.CharField(blank=True, max_length=120)),
|
||||||
|
('logo', models.FileField(blank=True, null=True, upload_to='business_logos/')),
|
||||||
|
('opening_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
|
||||||
|
('opening_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
|
||||||
|
('current_ecash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
|
||||||
|
('current_physical_cash', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='business_profile', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['user__username'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Transaction',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('client_name', models.CharField(max_length=120)),
|
||||||
|
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('transaction_type', models.CharField(choices=[('cash_out', 'Cash-out'), ('cash_in', 'Cash-In'), ('sending', 'Sending'), ('airtime', 'Airtime'), ('transfer', 'Transfer'), ('debt', 'Debt'), ('expenditure', 'Expenditure'), ('credit', 'Credit')], max_length=20)),
|
||||||
|
('service_charge', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12)),
|
||||||
|
('notes', models.CharField(blank=True, max_length=255)),
|
||||||
|
('ecash_before', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('ecash_after', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('physical_before', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('physical_after', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='core.businessprofile')),
|
||||||
|
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='momo_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at', '-id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
core/migrations/0002_businessprofile_mobile_token_version.py
Normal file
18
core/migrations/0002_businessprofile_mobile_token_version.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-04-17 02:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='businessprofile',
|
||||||
|
name='mobile_token_version',
|
||||||
|
field=models.PositiveIntegerField(default=1),
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
218
core/models.py
218
core/models.py
@ -1,3 +1,217 @@
|
|||||||
from django.db import models
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
# Create your models here.
|
from django.contrib.auth.models import User
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models, transaction
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessProfile(models.Model):
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='business_profile')
|
||||||
|
business_name = models.CharField(max_length=120, blank=True)
|
||||||
|
logo = models.FileField(upload_to='business_logos/', blank=True, null=True)
|
||||||
|
opening_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
opening_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
current_ecash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
current_physical_cash = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
mobile_token_version = models.PositiveIntegerField(default=1)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['user__username']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.business_name or f"{self.user.username}'s MoMo Business"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def owner_label(self):
|
||||||
|
return self.user.get_full_name() or self.user.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_cash(self):
|
||||||
|
return (self.current_ecash or Decimal('0.00')) + (self.current_physical_cash or Decimal('0.00'))
|
||||||
|
|
||||||
|
def sync_current_to_opening(self):
|
||||||
|
self.current_ecash = self.opening_ecash
|
||||||
|
self.current_physical_cash = self.opening_physical_cash
|
||||||
|
|
||||||
|
|
||||||
|
class Transaction(models.Model):
|
||||||
|
CASH_OUT = 'cash_out'
|
||||||
|
CASH_IN = 'cash_in'
|
||||||
|
SENDING = 'sending'
|
||||||
|
AIRTIME = 'airtime'
|
||||||
|
TRANSFER = 'transfer'
|
||||||
|
DEBT = 'debt'
|
||||||
|
EXPENDITURE = 'expenditure'
|
||||||
|
CREDIT = 'credit'
|
||||||
|
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
(CASH_OUT, 'Cash-out'),
|
||||||
|
(CASH_IN, 'Cash-In'),
|
||||||
|
(SENDING, 'Sending'),
|
||||||
|
(AIRTIME, 'Airtime'),
|
||||||
|
(TRANSFER, 'Transfer'),
|
||||||
|
(DEBT, 'Debt'),
|
||||||
|
(EXPENDITURE, 'Expenditure'),
|
||||||
|
(CREDIT, 'Credit'),
|
||||||
|
]
|
||||||
|
|
||||||
|
business = models.ForeignKey(BusinessProfile, on_delete=models.CASCADE, related_name='transactions')
|
||||||
|
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='momo_transactions')
|
||||||
|
client_name = models.CharField(max_length=120)
|
||||||
|
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
transaction_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
|
||||||
|
service_charge = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal('0.00'))
|
||||||
|
notes = models.CharField(max_length=255, blank=True)
|
||||||
|
ecash_before = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
ecash_after = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
physical_before = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
physical_after = models.DecimalField(max_digits=12, decimal_places=2)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at', '-id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_transaction_type_display()} · {self.client_name} · {self.amount}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _round(value: Decimal) -> Decimal:
|
||||||
|
return value.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate_effect(cls, transaction_type: str, amount: Decimal) -> tuple[Decimal, Decimal, Decimal]:
|
||||||
|
amount = cls._round(amount)
|
||||||
|
fee = Decimal('0.00')
|
||||||
|
ecash_delta = Decimal('0.00')
|
||||||
|
physical_delta = Decimal('0.00')
|
||||||
|
|
||||||
|
if transaction_type == cls.CASH_IN:
|
||||||
|
ecash_delta = -amount
|
||||||
|
physical_delta = amount
|
||||||
|
elif transaction_type == cls.CASH_OUT:
|
||||||
|
ecash_delta = amount
|
||||||
|
physical_delta = -amount
|
||||||
|
elif transaction_type in {cls.AIRTIME, cls.TRANSFER}:
|
||||||
|
ecash_delta = -amount
|
||||||
|
physical_delta = amount
|
||||||
|
elif transaction_type == cls.SENDING:
|
||||||
|
fee = cls._round(amount * Decimal('0.01'))
|
||||||
|
ecash_delta = -amount
|
||||||
|
physical_delta = amount + fee
|
||||||
|
elif transaction_type in {cls.DEBT, cls.EXPENDITURE}:
|
||||||
|
physical_delta = -amount
|
||||||
|
elif transaction_type == cls.CREDIT:
|
||||||
|
physical_delta = amount
|
||||||
|
else:
|
||||||
|
raise ValidationError('Unsupported transaction type.')
|
||||||
|
|
||||||
|
return cls._round(ecash_delta), cls._round(physical_delta), cls._round(fee)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_logged_transaction(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
business: BusinessProfile,
|
||||||
|
user: User,
|
||||||
|
client_name: str,
|
||||||
|
amount: Decimal,
|
||||||
|
transaction_type: str,
|
||||||
|
notes: str = '',
|
||||||
|
):
|
||||||
|
ecash_delta, physical_delta, fee = cls.calculate_effect(transaction_type, amount)
|
||||||
|
ecash_before = cls._round(business.current_ecash)
|
||||||
|
physical_before = cls._round(business.current_physical_cash)
|
||||||
|
ecash_after = cls._round(ecash_before + ecash_delta)
|
||||||
|
physical_after = cls._round(physical_before + physical_delta)
|
||||||
|
|
||||||
|
if ecash_after < 0:
|
||||||
|
raise ValidationError('This transaction would make e-cash go below zero.')
|
||||||
|
if physical_after < 0:
|
||||||
|
raise ValidationError('This transaction would make physical cash go below zero.')
|
||||||
|
|
||||||
|
entry = cls.objects.create(
|
||||||
|
business=business,
|
||||||
|
created_by=user,
|
||||||
|
client_name=client_name,
|
||||||
|
amount=cls._round(amount),
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
service_charge=fee,
|
||||||
|
notes=notes,
|
||||||
|
ecash_before=ecash_before,
|
||||||
|
ecash_after=ecash_after,
|
||||||
|
physical_before=physical_before,
|
||||||
|
physical_after=physical_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
business.current_ecash = ecash_after
|
||||||
|
business.current_physical_cash = physical_after
|
||||||
|
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
|
||||||
|
return entry
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def rebalance_business_ledger(cls, business: BusinessProfile):
|
||||||
|
business = BusinessProfile.objects.select_for_update().get(pk=business.pk)
|
||||||
|
ecash_balance = cls._round(business.opening_ecash)
|
||||||
|
physical_balance = cls._round(business.opening_physical_cash)
|
||||||
|
|
||||||
|
entries = list(
|
||||||
|
cls.objects.select_for_update()
|
||||||
|
.filter(business=business)
|
||||||
|
.order_by('created_at', 'id')
|
||||||
|
)
|
||||||
|
for entry in entries:
|
||||||
|
ecash_delta, physical_delta, fee = cls.calculate_effect(entry.transaction_type, entry.amount)
|
||||||
|
ecash_before = ecash_balance
|
||||||
|
physical_before = physical_balance
|
||||||
|
ecash_after = cls._round(ecash_before + ecash_delta)
|
||||||
|
physical_after = cls._round(physical_before + physical_delta)
|
||||||
|
|
||||||
|
if ecash_after < 0:
|
||||||
|
raise ValidationError('This change would make e-cash go below zero in your transaction history.')
|
||||||
|
if physical_after < 0:
|
||||||
|
raise ValidationError('This change would make physical cash go below zero in your transaction history.')
|
||||||
|
|
||||||
|
cls.objects.filter(pk=entry.pk).update(
|
||||||
|
service_charge=fee,
|
||||||
|
ecash_before=ecash_before,
|
||||||
|
ecash_after=ecash_after,
|
||||||
|
physical_before=physical_before,
|
||||||
|
physical_after=physical_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
ecash_balance = ecash_after
|
||||||
|
physical_balance = physical_after
|
||||||
|
|
||||||
|
business.current_ecash = ecash_balance
|
||||||
|
business.current_physical_cash = physical_balance
|
||||||
|
business.save(update_fields=['current_ecash', 'current_physical_cash', 'updated_at'])
|
||||||
|
return business
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def update_logged_transaction(self, *, client_name: str, amount: Decimal, transaction_type: str, notes: str = ''):
|
||||||
|
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
|
||||||
|
self.client_name = client_name
|
||||||
|
self.amount = self.__class__._round(amount)
|
||||||
|
self.transaction_type = transaction_type
|
||||||
|
self.notes = notes
|
||||||
|
self.save(update_fields=['client_name', 'amount', 'transaction_type', 'notes'])
|
||||||
|
business = self.__class__.rebalance_business_ledger(business)
|
||||||
|
refreshed_entry = self.__class__.objects.select_related('created_by').get(pk=self.pk)
|
||||||
|
return {
|
||||||
|
'transaction': refreshed_entry,
|
||||||
|
'business': business,
|
||||||
|
}
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def delete_logged_transaction(self):
|
||||||
|
business = BusinessProfile.objects.select_for_update().get(pk=self.business_id)
|
||||||
|
transaction_id = self.pk
|
||||||
|
self.delete()
|
||||||
|
business = self.__class__.rebalance_business_ledger(business)
|
||||||
|
return {
|
||||||
|
'transaction_id': transaction_id,
|
||||||
|
'business': business,
|
||||||
|
}
|
||||||
|
|||||||
@ -1,25 +1,72 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{% if project_description %}
|
<title>{% block title %}MoMoLedger{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<meta name="description" content="{% block meta_description %}{{ meta_description|default:project_description }}{% endblock %}">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
|
||||||
{% endif %}
|
|
||||||
{% if project_image_url %}
|
{% if project_image_url %}
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<meta property="og:image" content="{{ project_image_url }}">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<meta name="author" content="MoMoLedger">
|
||||||
|
<meta name="keywords" content="momo, mobile money, cash-in, cash-out, wallet report">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% block content %}{% endblock %}
|
<div class="page-shell">
|
||||||
</body>
|
<header class="site-header sticky-top">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark px-3 px-lg-4 py-3 momo-nav">
|
||||||
|
<div class="container-fluid px-0">
|
||||||
|
<a class="navbar-brand d-flex align-items-center gap-3" href="{% url 'home' %}">
|
||||||
|
<span class="brand-badge">M</span>
|
||||||
|
<span>
|
||||||
|
<span class="brand-name">MoMoLedger</span>
|
||||||
|
<small class="brand-subtitle d-block">Agent cashflow cockpit</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="mainNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'home' %}">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'transaction_list' %}">Transactions</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'reports' %}">Reports</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'profile' %}">Business setup</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/admin/">Admin</a></li>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'logout' %}">Log out</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item ms-lg-3"><a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a></li>
|
||||||
|
<li class="nav-item"><a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="container py-4 py-lg-5">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="message-stack mb-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags|default:'info' }} shadow-sm border-0" role="alert">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
47
core/templates/core/auth.html
Normal file
47
core/templates/core/auth.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="auth-layout row g-4 align-items-stretch justify-content-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<p class="card-kicker mb-1">{% if mode == 'signup' %}Create your secure agent space{% else %}Welcome back{% endif %}</p>
|
||||||
|
<h1 class="section-title mb-3">{{ page_title }}</h1>
|
||||||
|
<p class="muted-copy mb-4">{% if mode == 'signup' %}Create a single-agent account, then add your business name, logo, opening balances, and first transactions.{% else %}Log in to continue recording cash movements, viewing balances, and exporting reports.{% endif %}</p>
|
||||||
|
<div class="stack-list compact">
|
||||||
|
<div class="stack-item"><strong>Polished dashboard</strong><span>Custom MoMo styling, not default Bootstrap.</span></div>
|
||||||
|
<div class="stack-item"><strong>Smart balance engine</strong><span>Each transaction updates e-cash and physical cash automatically.</span></div>
|
||||||
|
<div class="stack-item"><strong>Printable reports</strong><span>Export PDF and use browser print for sharing.</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<form method="post" class="momo-form" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="form-help">{{ field.help_text }}</div>{% endif %}
|
||||||
|
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for error in form.non_field_errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-momo-primary btn-lg w-100 mt-4" type="submit">{% if mode == 'signup' %}Create account{% else %}Log in{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
<div class="auth-switch text-center mt-4">
|
||||||
|
{% if mode == 'signup' %}
|
||||||
|
<span>Already have an account?</span> <a class="text-link" href="{% url 'login' %}">Log in</a>
|
||||||
|
{% else %}
|
||||||
|
<span>Need an account?</span> <a class="text-link" href="{% url 'signup' %}">Sign up</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -1,145 +1,200 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
{% block head %}
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<section class="hero-panel mb-5 overflow-hidden">
|
||||||
<div class="card">
|
<div class="row align-items-center g-4 g-lg-5">
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="col-lg-7">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<span class="eyebrow">Mobile money bookkeeping made clear</span>
|
||||||
<span class="sr-only">Loading…</span>
|
<h1 class="display-title">Run your MoMo business with branded reports, balanced wallets, and cleaner daily records.</h1>
|
||||||
|
<p class="hero-copy">Track e-cash, physical cash, cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one polished workflow. MoMoLedger updates balances automatically and keeps your daily, weekly, monthly, and yearly reports ready to print or export as PDF.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mt-4">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<a class="btn btn-momo-primary btn-lg" href="{% url 'transaction_create' %}">Record a transaction</a>
|
||||||
|
<a class="btn btn-momo-ghost btn-lg" href="{% url 'reports' %}">Open reports</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-momo-primary btn-lg" href="{% url 'signup' %}">Create agent account</a>
|
||||||
|
<a class="btn btn-momo-ghost btn-lg" href="{% url 'login' %}">Sign in</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="hero-metrics row row-cols-1 row-cols-sm-3 g-3 mt-4">
|
||||||
|
<div class="col"><div class="metric-card"><strong>8</strong><span>Transaction types with smart balance rules</span></div></div>
|
||||||
|
<div class="col"><div class="metric-card"><strong>1%</strong><span>Sending fee calculated automatically</span></div></div>
|
||||||
|
<div class="col"><div class="metric-card"><strong>4</strong><span>Report ranges: day, week, month, year</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="glass-card auth-showcase">
|
||||||
|
<div class="row g-3 g-lg-4">
|
||||||
|
<div class="col-md-6 col-lg-12 col-xl-6">
|
||||||
|
<div class="soft-card h-100">
|
||||||
|
<p class="card-kicker">Welcome page</p>
|
||||||
|
<h2 class="section-title-sm">Sign up & login</h2>
|
||||||
|
<p class="muted-copy">Start with a secure agent account, then return to your dashboard anytime.</p>
|
||||||
|
<div class="d-grid gap-2 mt-3">
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'signup' %}">Sign up</a>
|
||||||
|
<a class="btn btn-momo-ghost" href="{% url 'login' %}">Log in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 col-lg-12 col-xl-6">
|
||||||
|
<div class="soft-card h-100">
|
||||||
|
<p class="card-kicker">Brand your reports</p>
|
||||||
|
<h2 class="section-title-sm">Business setup</h2>
|
||||||
|
<p class="muted-copy">Add your business name, logo, and opening balances so every report looks official.</p>
|
||||||
|
{% if request.user.is_authenticated and profile %}
|
||||||
|
<div class="profile-mini mt-3">
|
||||||
|
<div>
|
||||||
|
<strong>{{ profile.business_name|default:'Business name not set' }}</strong>
|
||||||
|
<span>{{ profile.owner_label }}</span>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-sm btn-momo-ghost" href="{% url 'profile' %}">Edit</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn btn-momo-ghost mt-3" href="{% url 'signup' %}">Create account to set profile</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
|
||||||
<p class="runtime">
|
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="shape shape-one"></div>
|
||||||
<footer>
|
<div class="shape shape-two"></div>
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
</section>
|
||||||
</footer>
|
|
||||||
{% endblock %}
|
{% if request.user.is_authenticated and profile %}
|
||||||
|
<section class="dashboard-grid mb-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="content-card mb-4">
|
||||||
|
<div class="section-header d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="card-kicker mb-1">Live balances</p>
|
||||||
|
<h2 class="section-title mb-0">{{ profile.business_name|default:'Your business dashboard' }}</h2>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">New transaction</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="balance-card balance-card-primary">
|
||||||
|
<span>E-cash</span>
|
||||||
|
<strong>{{ profile.current_ecash }}</strong>
|
||||||
|
<small>Opening {{ profile.opening_ecash }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="balance-card balance-card-secondary">
|
||||||
|
<span>Physical cash</span>
|
||||||
|
<strong>{{ profile.current_physical_cash }}</strong>
|
||||||
|
<small>Opening {{ profile.opening_physical_cash }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<article class="balance-card balance-card-accent">
|
||||||
|
<span>Today’s volume</span>
|
||||||
|
<strong>{{ today_total }}</strong>
|
||||||
|
<small>{{ today_count }} transactions · fees {{ today_fees }}</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-card">
|
||||||
|
<div class="section-header d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<p class="card-kicker mb-1">Recent activity</p>
|
||||||
|
<h2 class="section-title mb-0">Latest transactions</h2>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="{% url 'transaction_list' %}">View all</a>
|
||||||
|
</div>
|
||||||
|
{% if recent_transactions %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle momo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Fee</th>
|
||||||
|
<th>When</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for transaction in recent_transactions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ transaction.client_name }}</td>
|
||||||
|
<td><span class="badge momo-badge">{{ transaction.get_transaction_type_display }}</span></td>
|
||||||
|
<td>{{ transaction.amount }}</td>
|
||||||
|
<td>{{ transaction.service_charge }}</td>
|
||||||
|
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
|
||||||
|
<td><a class="text-link" href="{% url 'transaction_detail' transaction.id %}">Details</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No transactions yet</h3>
|
||||||
|
<p>Record your first cash movement to see balance updates and report activity.</p>
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Create first transaction</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="content-card mb-4">
|
||||||
|
<p class="card-kicker mb-1">Quick workflow</p>
|
||||||
|
<h2 class="section-title mb-3">What you can do now</h2>
|
||||||
|
<div class="stack-list">
|
||||||
|
<div class="stack-item">
|
||||||
|
<strong>1. Business setup</strong>
|
||||||
|
<span>Add name, logo, and opening balances.</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack-item">
|
||||||
|
<strong>2. Log transactions</strong>
|
||||||
|
<span>Client name + amount + type updates wallets instantly.</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack-item">
|
||||||
|
<strong>3. Export reports</strong>
|
||||||
|
<span>Daily, weekly, monthly, yearly, printable, and PDF-ready.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-card">
|
||||||
|
<p class="card-kicker mb-1">Report shortcuts</p>
|
||||||
|
<h2 class="section-title mb-3">Period summaries</h2>
|
||||||
|
<div class="report-shortcuts d-grid gap-2">
|
||||||
|
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=daily">Daily report</a>
|
||||||
|
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=weekly">Weekly report</a>
|
||||||
|
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=monthly">Monthly report</a>
|
||||||
|
<a class="btn btn-momo-ghost text-start" href="{% url 'reports' %}?period=yearly">Yearly report</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="content-card">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<p class="card-kicker mb-1">First delivery included</p>
|
||||||
|
<h2 class="section-title">A real workflow, not just a landing page</h2>
|
||||||
|
<p class="muted-copy">This first version includes account access, business setup, transaction logging with balance rules, and a branded report center with print and PDF export.</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-6"><div class="mini-feature"><strong>Cash-in</strong><span>e-cash decreases, physical cash increases.</span></div></div>
|
||||||
|
<div class="col-sm-6"><div class="mini-feature"><strong>Cash-out</strong><span>physical cash decreases, e-cash increases.</span></div></div>
|
||||||
|
<div class="col-sm-6"><div class="mini-feature"><strong>Sending</strong><span>1% fee added automatically.</span></div></div>
|
||||||
|
<div class="col-sm-6"><div class="mini-feature"><strong>Reports</strong><span>Daily to yearly export flow.</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
60
core/templates/core/profile_form.html
Normal file
60
core/templates/core/profile_form.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro mb-4">
|
||||||
|
<span class="eyebrow">Business setup</span>
|
||||||
|
<h1 class="section-title">Brand your MoMo reports</h1>
|
||||||
|
<p class="muted-copy">Set your business name, upload a logo, and define your opening wallet balances. These details appear across the dashboard and report exports.</p>
|
||||||
|
</section>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<article class="content-card">
|
||||||
|
<form method="post" enctype="multipart/form-data" class="momo-form" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="col-12 {% if field.name != 'logo' %}col-md-6{% endif %}">
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% if field.help_text %}<div class="form-help">{{ field.help_text }}</div>{% endif %}
|
||||||
|
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for error in form.non_field_errors %}<div class="form-error mt-3">{{ error }}</div>{% endfor %}
|
||||||
|
<button class="btn btn-momo-primary btn-lg mt-4" type="submit">Save business profile</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<p class="card-kicker mb-1">Report preview</p>
|
||||||
|
<h2 class="section-title mb-3">How your business appears</h2>
|
||||||
|
<div class="report-preview-card">
|
||||||
|
<div class="report-preview-header">
|
||||||
|
{% if profile.logo %}
|
||||||
|
<img src="{{ profile.logo.url }}" alt="{{ profile.business_name|default:'Business' }} logo" class="logo-thumb">
|
||||||
|
{% else %}
|
||||||
|
<div class="logo-placeholder">Logo</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<strong>{{ profile.business_name|default:'Business name will appear here' }}</strong>
|
||||||
|
<span>{{ profile.owner_label }}</span>
|
||||||
|
<span>{{ request.user.email|default:'Email not set yet' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-metrics">
|
||||||
|
<div><span>Opening e-cash</span><strong>{{ profile.opening_ecash }}</strong></div>
|
||||||
|
<div><span>Opening physical cash</span><strong>{{ profile.opening_physical_cash }}</strong></div>
|
||||||
|
<div><span>Current e-cash</span><strong>{{ profile.current_ecash }}</strong></div>
|
||||||
|
<div><span>Current physical cash</span><strong>{{ profile.current_physical_cash }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-help mt-3 mb-0">If you have not posted any transactions yet, saving this form also sets your starting current balances.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
127
core/templates/core/reports.html
Normal file
127
core/templates/core/reports.html
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Report centre</span>
|
||||||
|
<h1 class="section-title">Daily, weekly, monthly, and yearly reports</h1>
|
||||||
|
<p class="muted-copy">Filter your period, print the page, or download a branded PDF that includes your business name, logo, and user details.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button class="btn btn-momo-ghost" onclick="window.print()">Print report</button>
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'report_pdf' %}?period={{ form.cleaned_data.period|default:request.GET.period|default:'daily' }}{% if request.GET.start_date %}&start_date={{ request.GET.start_date }}{% endif %}{% if request.GET.end_date %}&end_date={{ request.GET.end_date }}{% endif %}">Download PDF</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<form method="get" class="momo-form" novalidate>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for field in form %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% for error in form.non_field_errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-momo-primary mt-4" type="submit">Refresh report</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<article class="content-card report-sheet">
|
||||||
|
<div class="report-sheet-header">
|
||||||
|
<div class="report-brand d-flex gap-3 align-items-center">
|
||||||
|
{% if profile.logo %}
|
||||||
|
<img src="{{ profile.logo.url }}" alt="{{ profile.business_name }} logo" class="logo-thumb">
|
||||||
|
{% else %}
|
||||||
|
<div class="logo-placeholder">Logo</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title mb-1">{{ profile.business_name|default:'MoMoLedger Business' }}</h2>
|
||||||
|
<p class="muted-copy mb-0">{{ profile.owner_label }} · {{ request.user.username }} · {{ request.user.email|default:'Email not set' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="report-window text-lg-end">
|
||||||
|
<span class="eyebrow">Report window</span>
|
||||||
|
<strong>{{ start_date }} to {{ end_date }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 my-1">
|
||||||
|
<div class="col-md-3"><div class="mini-stat"><span>Transactions</span><strong>{{ total_count }}</strong></div></div>
|
||||||
|
<div class="col-md-3"><div class="mini-stat"><span>Gross amount</span><strong>{{ total_amount }}</strong></div></div>
|
||||||
|
<div class="col-md-3"><div class="mini-stat"><span>Service fees</span><strong>{{ total_fees }}</strong></div></div>
|
||||||
|
<div class="col-md-3"><div class="mini-stat"><span>Closing cash</span><strong>{{ closing_physical }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-metrics two-up mb-4">
|
||||||
|
<div><span>Opening e-cash</span><strong>{{ profile.opening_ecash }}</strong></div>
|
||||||
|
<div><span>Opening physical cash</span><strong>{{ profile.opening_physical_cash }}</strong></div>
|
||||||
|
<div><span>Closing e-cash</span><strong>{{ closing_ecash }}</strong></div>
|
||||||
|
<div><span>Closing physical cash</span><strong>{{ closing_physical }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive mb-4">
|
||||||
|
<table class="table align-middle momo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Count</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Fees</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td>{{ row.count }}</td>
|
||||||
|
<td>{{ row.amount }}</td>
|
||||||
|
<td>{{ row.fees }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle momo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Fee</th>
|
||||||
|
<th>Balances after</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for transaction in entries %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
|
||||||
|
<td>{{ transaction.client_name }}</td>
|
||||||
|
<td>{{ transaction.get_transaction_type_display }}</td>
|
||||||
|
<td>{{ transaction.amount }}</td>
|
||||||
|
<td>{{ transaction.service_charge }}</td>
|
||||||
|
<td>E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="empty-state py-4">
|
||||||
|
<h3>No entries in this period</h3>
|
||||||
|
<p>Try another range or add a new transaction to populate the report.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
41
core/templates/core/transaction_detail.html
Normal file
41
core/templates/core/transaction_detail.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Transaction detail</span>
|
||||||
|
<h1 class="section-title">{{ transaction.get_transaction_type_display }} for {{ transaction.client_name }}</h1>
|
||||||
|
<p class="muted-copy">Saved on {{ transaction.created_at|date:"F d, Y H:i" }}.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-momo-ghost" href="{% url 'transaction_list' %}">Back to log</a>
|
||||||
|
</section>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<article class="content-card">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div><span>Business</span><strong>{{ profile.business_name }}</strong></div>
|
||||||
|
<div><span>Client</span><strong>{{ transaction.client_name }}</strong></div>
|
||||||
|
<div><span>Amount</span><strong>{{ transaction.amount }}</strong></div>
|
||||||
|
<div><span>Service charge</span><strong>{{ transaction.service_charge }}</strong></div>
|
||||||
|
<div><span>Recorded by</span><strong>{{ transaction.created_by.get_full_name|default:transaction.created_by.username }}</strong></div>
|
||||||
|
<div><span>Notes</span><strong>{{ transaction.notes|default:'—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<p class="card-kicker mb-1">Balance impact</p>
|
||||||
|
<h2 class="section-title mb-3">Wallet movement</h2>
|
||||||
|
<div class="movement-card">
|
||||||
|
<div><span>E-cash before</span><strong>{{ transaction.ecash_before }}</strong></div>
|
||||||
|
<div><span>E-cash after</span><strong>{{ transaction.ecash_after }}</strong></div>
|
||||||
|
<div><span>Physical before</span><strong>{{ transaction.physical_before }}</strong></div>
|
||||||
|
<div><span>Physical after</span><strong>{{ transaction.physical_after }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
48
core/templates/core/transaction_form.html
Normal file
48
core/templates/core/transaction_form.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro mb-4">
|
||||||
|
<span class="eyebrow">Transaction input</span>
|
||||||
|
<h1 class="section-title">Record a new cash movement</h1>
|
||||||
|
<p class="muted-copy">Choose a transaction type and MoMoLedger applies the wallet movement rules for you. Defaults: Debt and Expenditure reduce physical cash, while Credit increases physical cash.</p>
|
||||||
|
</section>
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<article class="content-card">
|
||||||
|
<form method="post" class="momo-form" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="col-12 {% if field.name != 'notes' %}col-md-6{% endif %}">
|
||||||
|
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||||
|
{{ field }}
|
||||||
|
{% for error in field.errors %}<div class="form-error">{{ error }}</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% for error in form.non_field_errors %}<div class="form-error mt-3">{{ error }}</div>{% endfor %}
|
||||||
|
<button class="btn btn-momo-primary btn-lg mt-4" type="submit">Save transaction</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<article class="content-card h-100">
|
||||||
|
<p class="card-kicker mb-1">Current wallet state</p>
|
||||||
|
<h2 class="section-title mb-3">{{ profile.business_name }}</h2>
|
||||||
|
<div class="preview-metrics two-up">
|
||||||
|
<div><span>E-cash available</span><strong>{{ profile.current_ecash }}</strong></div>
|
||||||
|
<div><span>Physical cash available</span><strong>{{ profile.current_physical_cash }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="rule-list mt-4">
|
||||||
|
<div class="rule-item"><strong>Cash-In</strong><span>e-cash − amount, physical cash + amount</span></div>
|
||||||
|
<div class="rule-item"><strong>Cash-out</strong><span>physical cash − amount, e-cash + amount</span></div>
|
||||||
|
<div class="rule-item"><strong>Sending</strong><span>e-cash − amount, physical cash + amount + 1% fee</span></div>
|
||||||
|
<div class="rule-item"><strong>Airtime / Transfer</strong><span>e-cash − amount, physical cash + amount</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
58
core/templates/core/transaction_list.html
Normal file
58
core/templates/core/transaction_list.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ page_title }}{% endblock %}
|
||||||
|
{% block meta_description %}{{ meta_description }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">Transaction log</span>
|
||||||
|
<h1 class="section-title">All recorded transactions</h1>
|
||||||
|
<p class="muted-copy">Review entries, totals, and drill into the exact balance movements for each one.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Add transaction</a>
|
||||||
|
</section>
|
||||||
|
<section class="row g-4 mb-4">
|
||||||
|
<div class="col-md-4"><article class="balance-card balance-card-primary"><span>Total entries</span><strong>{{ summary.total_count|default:0 }}</strong></article></div>
|
||||||
|
<div class="col-md-4"><article class="balance-card balance-card-secondary"><span>Total amount</span><strong>{{ summary.total_amount|default:0 }}</strong></article></div>
|
||||||
|
<div class="col-md-4"><article class="balance-card balance-card-accent"><span>Total service fees</span><strong>{{ summary.total_fees|default:0 }}</strong></article></div>
|
||||||
|
</section>
|
||||||
|
<section class="content-card">
|
||||||
|
{% if transactions %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table align-middle momo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Service charge</th>
|
||||||
|
<th>Balances after</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for transaction in transactions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ transaction.client_name }}</td>
|
||||||
|
<td><span class="badge momo-badge">{{ transaction.get_transaction_type_display }}</span></td>
|
||||||
|
<td>{{ transaction.amount }}</td>
|
||||||
|
<td>{{ transaction.service_charge }}</td>
|
||||||
|
<td>E {{ transaction.ecash_after }} · P {{ transaction.physical_after }}</td>
|
||||||
|
<td>{{ transaction.created_at|date:"M d, Y H:i" }}</td>
|
||||||
|
<td><a class="text-link" href="{% url 'transaction_detail' transaction.id %}">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No entries yet</h3>
|
||||||
|
<p>Your transaction log will appear here after the first saved record.</p>
|
||||||
|
<a class="btn btn-momo-primary" href="{% url 'transaction_create' %}">Create first transaction</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@ -1,3 +1,46 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
# Create your tests here.
|
from .models import BusinessProfile, Transaction
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionLogicTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username='agent', password='secret123')
|
||||||
|
self.profile = BusinessProfile.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
business_name='BrightPay',
|
||||||
|
opening_ecash=Decimal('1000.00'),
|
||||||
|
opening_physical_cash=Decimal('500.00'),
|
||||||
|
current_ecash=Decimal('1000.00'),
|
||||||
|
current_physical_cash=Decimal('500.00'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cash_in_moves_value_from_ecash_to_physical_cash(self):
|
||||||
|
entry = Transaction.create_logged_transaction(
|
||||||
|
business=self.profile,
|
||||||
|
user=self.user,
|
||||||
|
client_name='Ama',
|
||||||
|
amount=Decimal('100.00'),
|
||||||
|
transaction_type=Transaction.CASH_IN,
|
||||||
|
)
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(entry.ecash_after, Decimal('900.00'))
|
||||||
|
self.assertEqual(entry.physical_after, Decimal('600.00'))
|
||||||
|
self.assertEqual(self.profile.current_ecash, Decimal('900.00'))
|
||||||
|
self.assertEqual(self.profile.current_physical_cash, Decimal('600.00'))
|
||||||
|
|
||||||
|
def test_sending_adds_one_percent_service_charge_to_physical_cash(self):
|
||||||
|
entry = Transaction.create_logged_transaction(
|
||||||
|
business=self.profile,
|
||||||
|
user=self.user,
|
||||||
|
client_name='Kojo',
|
||||||
|
amount=Decimal('200.00'),
|
||||||
|
transaction_type=Transaction.SENDING,
|
||||||
|
)
|
||||||
|
self.profile.refresh_from_db()
|
||||||
|
self.assertEqual(entry.service_charge, Decimal('2.00'))
|
||||||
|
self.assertEqual(entry.ecash_after, Decimal('800.00'))
|
||||||
|
self.assertEqual(entry.physical_after, Decimal('702.00'))
|
||||||
|
|||||||
39
core/urls.py
39
core/urls.py
@ -1,7 +1,42 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import (
|
||||||
|
api_health_view,
|
||||||
|
api_login_view,
|
||||||
|
api_logout_view,
|
||||||
|
api_profile_view,
|
||||||
|
api_token_refresh_view,
|
||||||
|
api_transaction_detail_view,
|
||||||
|
api_transactions_view,
|
||||||
|
home,
|
||||||
|
login_view,
|
||||||
|
logout_view,
|
||||||
|
profile_view,
|
||||||
|
report_pdf_view,
|
||||||
|
reports_view,
|
||||||
|
signup_view,
|
||||||
|
transaction_create_view,
|
||||||
|
transaction_detail_view,
|
||||||
|
transaction_list_view,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path('', home, name='home'),
|
||||||
|
path('api/health/', api_health_view, name='api_health'),
|
||||||
|
path('api/login/', api_login_view, name='api_login'),
|
||||||
|
path('api/token/', api_login_view, name='api_token_login'),
|
||||||
|
path('api/token/refresh/', api_token_refresh_view, name='api_token_refresh'),
|
||||||
|
path('api/logout/', api_logout_view, name='api_logout'),
|
||||||
|
path('api/profile/', api_profile_view, name='api_profile'),
|
||||||
|
path('api/transactions/', api_transactions_view, name='api_transactions'),
|
||||||
|
path('api/transactions/<int:transaction_id>/', api_transaction_detail_view, name='api_transaction_detail'),
|
||||||
|
path('signup/', signup_view, name='signup'),
|
||||||
|
path('login/', login_view, name='login'),
|
||||||
|
path('logout/', logout_view, name='logout'),
|
||||||
|
path('profile/', profile_view, name='profile'),
|
||||||
|
path('transactions/new/', transaction_create_view, name='transaction_create'),
|
||||||
|
path('transactions/', transaction_list_view, name='transaction_list'),
|
||||||
|
path('transactions/<int:transaction_id>/', transaction_detail_view, name='transaction_detail'),
|
||||||
|
path('reports/', reports_view, name='reports'),
|
||||||
|
path('reports/pdf/', report_pdf_view, name='report_pdf'),
|
||||||
]
|
]
|
||||||
|
|||||||
716
core/views.py
716
core/views.py
@ -1,25 +1,705 @@
|
|||||||
import os
|
import json
|
||||||
import platform
|
from datetime import datetime, time
|
||||||
|
from functools import wraps
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
from django import get_version as django_version
|
from django.contrib import messages
|
||||||
from django.shortcuts import render
|
from django.contrib.auth import authenticate, login, logout
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db.models import Count, Sum
|
||||||
|
from django.http import FileResponse, Http404, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.decorators.http import require_GET, require_POST, require_http_methods
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .forms import BusinessProfileForm, LoginForm, ReportFilterForm, SignUpForm, TransactionForm
|
||||||
|
from .jwt_auth import (
|
||||||
|
JWTAuthError,
|
||||||
|
authenticate_authorization_header,
|
||||||
|
authenticate_refresh_token,
|
||||||
|
issue_token_pair,
|
||||||
|
revoke_user_tokens,
|
||||||
|
)
|
||||||
|
from .models import BusinessProfile, Transaction
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_authenticated_user(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip()
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user, _ = authenticate_authorization_header(authorization)
|
||||||
|
request.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def api_login_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped(request, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
user = get_api_authenticated_user(request)
|
||||||
|
except JWTAuthError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.message,
|
||||||
|
'code': exc.code,
|
||||||
|
}, status=exc.status_code)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Authentication required.',
|
||||||
|
'code': 'auth_required',
|
||||||
|
}, status=401)
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_transaction(entry):
|
||||||
|
return {
|
||||||
|
'id': entry.id,
|
||||||
|
'client_name': entry.client_name,
|
||||||
|
'amount': str(entry.amount),
|
||||||
|
'transaction_type': entry.transaction_type,
|
||||||
|
'transaction_type_label': entry.get_transaction_type_display(),
|
||||||
|
'service_charge': str(entry.service_charge),
|
||||||
|
'notes': entry.notes,
|
||||||
|
'ecash_before': str(entry.ecash_before),
|
||||||
|
'ecash_after': str(entry.ecash_after),
|
||||||
|
'physical_before': str(entry.physical_before),
|
||||||
|
'physical_after': str(entry.physical_after),
|
||||||
|
'created_by': entry.created_by.username,
|
||||||
|
'created_at': timezone.localtime(entry.created_at).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(user):
|
||||||
|
profile, _ = BusinessProfile.objects.get_or_create(user=user)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
def build_report_snapshot(profile, params=None):
|
||||||
|
form = ReportFilterForm(params or None)
|
||||||
|
form.is_valid()
|
||||||
|
start_date, end_date = form.get_range() if form.cleaned_data else (timezone.localdate(), timezone.localdate())
|
||||||
|
start_dt = timezone.make_aware(datetime.combine(start_date, time.min))
|
||||||
|
end_dt = timezone.make_aware(datetime.combine(end_date, time.max))
|
||||||
|
entries = profile.transactions.filter(created_at__range=(start_dt, end_dt)).select_related('created_by')
|
||||||
|
summary_rows = entries.values('transaction_type').annotate(
|
||||||
|
total_amount=Sum('amount'),
|
||||||
|
total_fees=Sum('service_charge'),
|
||||||
|
total_count=Count('id'),
|
||||||
|
)
|
||||||
|
summary_map = {row['transaction_type']: row for row in summary_rows}
|
||||||
|
ordered_summary = []
|
||||||
|
for value, label in Transaction.TYPE_CHOICES:
|
||||||
|
row = summary_map.get(value)
|
||||||
|
ordered_summary.append({
|
||||||
|
'key': value,
|
||||||
|
'label': label,
|
||||||
|
'count': row['total_count'] if row else 0,
|
||||||
|
'amount': row['total_amount'] if row and row['total_amount'] else 0,
|
||||||
|
'fees': row['total_fees'] if row and row['total_fees'] else 0,
|
||||||
|
})
|
||||||
|
totals = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'))
|
||||||
|
latest_entry = entries.order_by('-created_at', '-id').first()
|
||||||
|
closing_ecash = latest_entry.ecash_after if latest_entry else profile.current_ecash
|
||||||
|
closing_physical = latest_entry.physical_after if latest_entry else profile.current_physical_cash
|
||||||
|
return {
|
||||||
|
'form': form,
|
||||||
|
'entries': entries.order_by('-created_at', '-id'),
|
||||||
|
'summary': ordered_summary,
|
||||||
|
'total_amount': totals['total_amount'] or 0,
|
||||||
|
'total_fees': totals['total_fees'] or 0,
|
||||||
|
'total_count': totals['total_count'] or 0,
|
||||||
|
'start_date': start_date,
|
||||||
|
'end_date': end_date,
|
||||||
|
'closing_ecash': closing_ecash,
|
||||||
|
'closing_physical': closing_physical,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
"""Render the landing screen with loader and environment details."""
|
|
||||||
host_name = request.get_host().lower()
|
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
|
||||||
now = timezone.now()
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
'page_title': 'MoMoLedger | Agent wallet dashboard',
|
||||||
"agent_brand": agent_brand,
|
'meta_description': 'Manage MoMo transactions, balances, business branding, and daily-to-yearly reports from one polished dashboard.',
|
||||||
"django_version": django_version(),
|
'signup_form': SignUpForm(),
|
||||||
"python_version": platform.python_version(),
|
'login_form': LoginForm(request=request),
|
||||||
"current_time": now,
|
|
||||||
"host_name": host_name,
|
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
if request.user.is_authenticated:
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
report_snapshot = build_report_snapshot(profile, {'period': 'daily'})
|
||||||
|
context.update({
|
||||||
|
'profile': profile,
|
||||||
|
'recent_transactions': profile.transactions.select_related('created_by')[:5],
|
||||||
|
'today_total': report_snapshot['total_amount'],
|
||||||
|
'today_fees': report_snapshot['total_fees'],
|
||||||
|
'today_count': report_snapshot['total_count'],
|
||||||
|
})
|
||||||
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def signup_view(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('home')
|
||||||
|
form = SignUpForm(request.POST or None)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
BusinessProfile.objects.get_or_create(user=user)
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, 'Welcome! Your account is ready. Set your business details next.')
|
||||||
|
return redirect('profile')
|
||||||
|
return render(request, 'core/auth.html', {
|
||||||
|
'form': form,
|
||||||
|
'mode': 'signup',
|
||||||
|
'page_title': 'Create your MoMo account',
|
||||||
|
'meta_description': 'Create your mobile money business account and start tracking your balances securely.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def login_view(request):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('home')
|
||||||
|
form = LoginForm(request=request, data=request.POST or None)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
login(request, form.get_user())
|
||||||
|
messages.success(request, 'Welcome back to your MoMo dashboard.')
|
||||||
|
return redirect('home')
|
||||||
|
return render(request, 'core/auth.html', {
|
||||||
|
'form': form,
|
||||||
|
'mode': 'login',
|
||||||
|
'page_title': 'Log in to MoMoLedger',
|
||||||
|
'meta_description': 'Access your mobile money dashboard, balances, transaction log, and reports.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def logout_view(request):
|
||||||
|
logout(request)
|
||||||
|
messages.info(request, 'You have been logged out.')
|
||||||
|
return redirect('home')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def profile_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
form = BusinessProfileForm(request.POST or None, request.FILES or None, instance=profile)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
profile = form.save()
|
||||||
|
messages.success(request, 'Business profile saved. Your branding will now appear on reports.')
|
||||||
|
return redirect('profile')
|
||||||
|
return render(request, 'core/profile_form.html', {
|
||||||
|
'form': form,
|
||||||
|
'profile': profile,
|
||||||
|
'page_title': 'Business setup | MoMoLedger',
|
||||||
|
'meta_description': 'Update your business name, logo, and opening wallet balances for branded reports.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def transaction_create_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
if not profile.business_name:
|
||||||
|
messages.info(request, 'Start by saving your business profile before logging transactions.')
|
||||||
|
return redirect('profile')
|
||||||
|
form = TransactionForm(request.POST or None, business=profile)
|
||||||
|
if request.method == 'POST' and form.is_valid():
|
||||||
|
entry = form.save(request.user)
|
||||||
|
messages.success(request, 'Transaction recorded and balances updated automatically.')
|
||||||
|
return redirect('transaction_detail', transaction_id=entry.id)
|
||||||
|
return render(request, 'core/transaction_form.html', {
|
||||||
|
'form': form,
|
||||||
|
'profile': profile,
|
||||||
|
'page_title': 'New transaction | MoMoLedger',
|
||||||
|
'meta_description': 'Record cash-in, cash-out, sending, airtime, transfer, debt, expenditure, and credit in one place.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def transaction_list_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
entries = profile.transactions.select_related('created_by')
|
||||||
|
summary = entries.aggregate(total_amount=Sum('amount'), total_fees=Sum('service_charge'), total_count=Count('id'))
|
||||||
|
return render(request, 'core/transaction_list.html', {
|
||||||
|
'profile': profile,
|
||||||
|
'transactions': entries,
|
||||||
|
'summary': summary,
|
||||||
|
'page_title': 'Transactions | MoMoLedger',
|
||||||
|
'meta_description': 'Browse recent mobile money transactions and review balance movements.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def transaction_detail_view(request, transaction_id):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id)
|
||||||
|
return render(request, 'core/transaction_detail.html', {
|
||||||
|
'profile': profile,
|
||||||
|
'transaction': entry,
|
||||||
|
'page_title': f'{entry.get_transaction_type_display()} details | MoMoLedger',
|
||||||
|
'meta_description': 'Inspect one MoMo transaction and see the exact e-cash and physical cash balance effect.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def reports_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'})
|
||||||
|
context = {
|
||||||
|
'profile': profile,
|
||||||
|
'page_title': 'Reports | MoMoLedger',
|
||||||
|
'meta_description': 'View printable MoMo business reports by day, week, month, year, or a custom date range.',
|
||||||
|
**snapshot,
|
||||||
|
}
|
||||||
|
return render(request, 'core/reports.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def report_pdf_view(request):
|
||||||
|
try:
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.units import cm
|
||||||
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
raise Http404('PDF support is not available.') from exc
|
||||||
|
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
snapshot = build_report_snapshot(profile, request.GET or {'period': 'daily'})
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
pdf = canvas.Canvas(buffer, pagesize=A4)
|
||||||
|
width, height = A4
|
||||||
|
margin = 1.5 * cm
|
||||||
|
y = height - margin
|
||||||
|
|
||||||
|
pdf.setTitle('MoMo report')
|
||||||
|
pdf.setFillColor(colors.HexColor('#0f6f5c'))
|
||||||
|
pdf.rect(0, height - 4 * cm, width, 4 * cm, fill=1, stroke=0)
|
||||||
|
pdf.setFillColor(colors.white)
|
||||||
|
pdf.setFont('Helvetica-Bold', 18)
|
||||||
|
pdf.drawString(margin, height - 1.5 * cm, profile.business_name or 'MoMoLedger Business')
|
||||||
|
pdf.setFont('Helvetica', 10)
|
||||||
|
owner_text = f"Owner: {profile.owner_label} | User: {request.user.username} | Email: {request.user.email or 'Not set'}"
|
||||||
|
pdf.drawString(margin, height - 2.2 * cm, owner_text[:100])
|
||||||
|
pdf.drawString(margin, height - 2.8 * cm, f"Report window: {snapshot['start_date']} to {snapshot['end_date']}")
|
||||||
|
|
||||||
|
if profile.logo:
|
||||||
|
try:
|
||||||
|
pdf.drawImage(profile.logo.path, width - 4.5 * cm, height - 3.2 * cm, width=2.5 * cm, height=2.5 * cm, preserveAspectRatio=True, mask='auto')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
y = height - 5.2 * cm
|
||||||
|
pdf.setFillColor(colors.HexColor('#143642'))
|
||||||
|
pdf.setFont('Helvetica-Bold', 12)
|
||||||
|
pdf.drawString(margin, y, 'Balance summary')
|
||||||
|
y -= 0.6 * cm
|
||||||
|
pdf.setFont('Helvetica', 10)
|
||||||
|
metrics = [
|
||||||
|
f"Opening e-cash: {profile.opening_ecash}",
|
||||||
|
f"Opening physical: {profile.opening_physical_cash}",
|
||||||
|
f"Closing e-cash: {snapshot['closing_ecash']}",
|
||||||
|
f"Closing physical: {snapshot['closing_physical']}",
|
||||||
|
f"Transactions: {snapshot['total_count']}",
|
||||||
|
f"Gross amount: {snapshot['total_amount']}",
|
||||||
|
f"Service fees: {snapshot['total_fees']}",
|
||||||
|
]
|
||||||
|
for item in metrics:
|
||||||
|
pdf.drawString(margin, y, item)
|
||||||
|
y -= 0.45 * cm
|
||||||
|
|
||||||
|
y -= 0.2 * cm
|
||||||
|
pdf.setFont('Helvetica-Bold', 12)
|
||||||
|
pdf.drawString(margin, y, 'Transaction type totals')
|
||||||
|
y -= 0.6 * cm
|
||||||
|
pdf.setFont('Helvetica-Bold', 10)
|
||||||
|
pdf.drawString(margin, y, 'Type')
|
||||||
|
pdf.drawString(8.2 * cm, y, 'Count')
|
||||||
|
pdf.drawString(11 * cm, y, 'Amount')
|
||||||
|
pdf.drawString(15 * cm, y, 'Fees')
|
||||||
|
y -= 0.35 * cm
|
||||||
|
pdf.setStrokeColor(colors.HexColor('#d4dfd6'))
|
||||||
|
pdf.line(margin, y, width - margin, y)
|
||||||
|
y -= 0.45 * cm
|
||||||
|
pdf.setFont('Helvetica', 10)
|
||||||
|
for row in snapshot['summary']:
|
||||||
|
if y < 3 * cm:
|
||||||
|
pdf.showPage()
|
||||||
|
y = height - margin
|
||||||
|
pdf.drawString(margin, y, row['label'])
|
||||||
|
pdf.drawString(8.2 * cm, y, str(row['count']))
|
||||||
|
pdf.drawString(11 * cm, y, str(row['amount']))
|
||||||
|
pdf.drawString(15 * cm, y, str(row['fees']))
|
||||||
|
y -= 0.45 * cm
|
||||||
|
|
||||||
|
y -= 0.3 * cm
|
||||||
|
pdf.setFont('Helvetica-Bold', 12)
|
||||||
|
pdf.drawString(margin, y, 'Recent entries')
|
||||||
|
y -= 0.6 * cm
|
||||||
|
pdf.setFont('Helvetica', 9)
|
||||||
|
for entry in snapshot['entries'][:12]:
|
||||||
|
if y < 2.5 * cm:
|
||||||
|
pdf.showPage()
|
||||||
|
y = height - margin
|
||||||
|
label = f"{timezone.localtime(entry.created_at).strftime('%Y-%m-%d %H:%M')} · {entry.get_transaction_type_display()} · {entry.client_name} · {entry.amount}"
|
||||||
|
max_width = width - (2 * margin)
|
||||||
|
while stringWidth(label, 'Helvetica', 9) > max_width and len(label) > 3:
|
||||||
|
label = label[:-4] + '...'
|
||||||
|
pdf.drawString(margin, y, label)
|
||||||
|
y -= 0.42 * cm
|
||||||
|
|
||||||
|
pdf.showPage()
|
||||||
|
pdf.save()
|
||||||
|
buffer.seek(0)
|
||||||
|
filename = f"momo-report-{snapshot['start_date']}-to-{snapshot['end_date']}.pdf"
|
||||||
|
return FileResponse(buffer, as_attachment=True, filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_api_payload(request):
|
||||||
|
if (request.content_type or '').startswith('application/json'):
|
||||||
|
try:
|
||||||
|
return json.loads(request.body.decode('utf-8') or '{}')
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
return request.POST
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_form_errors(form):
|
||||||
|
errors = {}
|
||||||
|
for field, items in form.errors.get_json_data().items():
|
||||||
|
errors[field] = [item['message'] for item in items]
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def build_transaction_summary(profile):
|
||||||
|
summary = profile.transactions.aggregate(
|
||||||
|
total_amount=Sum('amount'),
|
||||||
|
total_fees=Sum('service_charge'),
|
||||||
|
total_count=Count('id'),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'total_amount': str(summary['total_amount'] or 0),
|
||||||
|
'total_fees': str(summary['total_fees'] or 0),
|
||||||
|
'total_count': summary['total_count'] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
def api_health_view(request):
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'app': 'MoMoLedger API',
|
||||||
|
'message': 'Android backend starter endpoints are available.',
|
||||||
|
'server_time': timezone.now().isoformat(),
|
||||||
|
'authenticated': bool(request.user.is_authenticated or (request.META.get('HTTP_AUTHORIZATION') or '').strip()),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
|
def api_login_view(request):
|
||||||
|
user = request.user if request.user.is_authenticated else None
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
payload = parse_api_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid JSON body.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
username = (payload.get('username') or '').strip()
|
||||||
|
password = payload.get('password') or ''
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Username and password are required.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
user = authenticate(request, username=username, password=password)
|
||||||
|
if user is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid username or password.',
|
||||||
|
}, status=401)
|
||||||
|
|
||||||
|
login(request, user)
|
||||||
|
message = 'Login successful.'
|
||||||
|
else:
|
||||||
|
message = 'Already logged in.'
|
||||||
|
|
||||||
|
profile = get_profile(user)
|
||||||
|
tokens = issue_token_pair(user)
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': message,
|
||||||
|
'user': {
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'business_name': profile.business_name,
|
||||||
|
},
|
||||||
|
'tokens': tokens,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
|
def api_token_refresh_view(request):
|
||||||
|
payload = parse_api_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid JSON body.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip()
|
||||||
|
try:
|
||||||
|
user, profile = authenticate_refresh_token(refresh_token)
|
||||||
|
except JWTAuthError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.message,
|
||||||
|
'code': exc.code,
|
||||||
|
}, status=exc.status_code)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Token refreshed successfully.',
|
||||||
|
'user': {
|
||||||
|
'id': user.id,
|
||||||
|
'username': user.username,
|
||||||
|
'email': user.email,
|
||||||
|
'business_name': profile.business_name,
|
||||||
|
},
|
||||||
|
'tokens': issue_token_pair(user),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_POST
|
||||||
|
def api_logout_view(request):
|
||||||
|
payload = parse_api_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid JSON body.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
user = request.user if request.user.is_authenticated else None
|
||||||
|
refresh_token = (payload.get('refresh_token') or payload.get('refresh') or '').strip()
|
||||||
|
|
||||||
|
if user is None and refresh_token:
|
||||||
|
try:
|
||||||
|
user, _ = authenticate_refresh_token(refresh_token)
|
||||||
|
except JWTAuthError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.message,
|
||||||
|
'code': exc.code,
|
||||||
|
}, status=exc.status_code)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
authorization = (request.META.get('HTTP_AUTHORIZATION') or '').strip()
|
||||||
|
if authorization:
|
||||||
|
try:
|
||||||
|
user, _ = authenticate_authorization_header(authorization)
|
||||||
|
except JWTAuthError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.message,
|
||||||
|
'code': exc.code,
|
||||||
|
}, status=exc.status_code)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'No active session.',
|
||||||
|
})
|
||||||
|
|
||||||
|
revoke_user_tokens(user)
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
logout(request)
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Logout successful. Mobile tokens revoked.',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@api_login_required
|
||||||
|
def api_profile_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'profile': {
|
||||||
|
'username': request.user.username,
|
||||||
|
'email': request.user.email,
|
||||||
|
'owner_label': profile.owner_label,
|
||||||
|
'business_name': profile.business_name,
|
||||||
|
'opening_ecash': str(profile.opening_ecash),
|
||||||
|
'opening_physical_cash': str(profile.opening_physical_cash),
|
||||||
|
'current_ecash': str(profile.current_ecash),
|
||||||
|
'current_physical_cash': str(profile.current_physical_cash),
|
||||||
|
'total_cash': str(profile.total_cash),
|
||||||
|
'created_at': timezone.localtime(profile.created_at).isoformat(),
|
||||||
|
'updated_at': timezone.localtime(profile.updated_at).isoformat(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_login_required
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def api_transactions_view(request):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
payload = parse_api_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid JSON body.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
form = TransactionForm(payload or None, business=profile)
|
||||||
|
if not form.is_valid():
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Validation failed.',
|
||||||
|
'errors': serialize_form_errors(form),
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry = form.save(request.user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.messages[0] if exc.messages else 'Could not create transaction.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
profile.refresh_from_db(fields=['current_ecash', 'current_physical_cash'])
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Transaction created successfully.',
|
||||||
|
'transaction': serialize_transaction(entry),
|
||||||
|
'balances': {
|
||||||
|
'current_ecash': str(profile.current_ecash),
|
||||||
|
'current_physical_cash': str(profile.current_physical_cash),
|
||||||
|
'total_cash': str(profile.total_cash),
|
||||||
|
},
|
||||||
|
'summary': build_transaction_summary(profile),
|
||||||
|
}, status=201)
|
||||||
|
|
||||||
|
try:
|
||||||
|
limit = int(request.GET.get('limit', 20))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 20
|
||||||
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
|
entries = list(profile.transactions.select_related('created_by')[:limit])
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'count': len(entries),
|
||||||
|
'limit': limit,
|
||||||
|
'summary': build_transaction_summary(profile),
|
||||||
|
'transactions': [serialize_transaction(entry) for entry in entries],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_login_required
|
||||||
|
@require_http_methods(['GET', 'PUT', 'PATCH', 'DELETE'])
|
||||||
|
def api_transaction_detail_view(request, transaction_id):
|
||||||
|
profile = get_profile(request.user)
|
||||||
|
entry = get_object_or_404(profile.transactions.select_related('created_by'), id=transaction_id)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'transaction': serialize_transaction(entry),
|
||||||
|
'balances': {
|
||||||
|
'current_ecash': str(profile.current_ecash),
|
||||||
|
'current_physical_cash': str(profile.current_physical_cash),
|
||||||
|
'total_cash': str(profile.total_cash),
|
||||||
|
},
|
||||||
|
'summary': build_transaction_summary(profile),
|
||||||
|
})
|
||||||
|
|
||||||
|
if request.method in {'PUT', 'PATCH'}:
|
||||||
|
payload = parse_api_payload(request)
|
||||||
|
if payload is None:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Invalid JSON body.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
merged_payload = {
|
||||||
|
'client_name': entry.client_name,
|
||||||
|
'amount': str(entry.amount),
|
||||||
|
'transaction_type': entry.transaction_type,
|
||||||
|
'notes': entry.notes,
|
||||||
|
}
|
||||||
|
merged_payload.update(payload)
|
||||||
|
form = TransactionForm(merged_payload, business=profile, instance=entry)
|
||||||
|
if not form.is_valid():
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': 'Validation failed.',
|
||||||
|
'errors': serialize_form_errors(form),
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_result = form.save(request.user)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.messages[0] if exc.messages else 'Could not update transaction.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
profile = update_result['business']
|
||||||
|
entry = update_result['transaction']
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Transaction updated successfully.',
|
||||||
|
'transaction': serialize_transaction(entry),
|
||||||
|
'balances': {
|
||||||
|
'current_ecash': str(profile.current_ecash),
|
||||||
|
'current_physical_cash': str(profile.current_physical_cash),
|
||||||
|
'total_cash': str(profile.total_cash),
|
||||||
|
},
|
||||||
|
'summary': build_transaction_summary(profile),
|
||||||
|
})
|
||||||
|
|
||||||
|
deleted_transaction = serialize_transaction(entry)
|
||||||
|
try:
|
||||||
|
delete_result = entry.delete_logged_transaction()
|
||||||
|
except ValidationError as exc:
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': False,
|
||||||
|
'error': exc.messages[0] if exc.messages else 'Could not delete transaction.',
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
profile = delete_result['business']
|
||||||
|
return JsonResponse({
|
||||||
|
'ok': True,
|
||||||
|
'message': 'Transaction deleted successfully.',
|
||||||
|
'deleted_transaction': deleted_transaction,
|
||||||
|
'deleted_transaction_id': delete_result['transaction_id'],
|
||||||
|
'balances': {
|
||||||
|
'current_ecash': str(profile.current_ecash),
|
||||||
|
'current_physical_cash': str(profile.current_physical_cash),
|
||||||
|
'total_cash': str(profile.total_cash),
|
||||||
|
},
|
||||||
|
'summary': build_transaction_summary(profile),
|
||||||
|
})
|
||||||
|
|||||||
BIN
media/business_logos/IMG_4442.JPG
Normal file
BIN
media/business_logos/IMG_4442.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@ -1,3 +1,5 @@
|
|||||||
Django==5.2.7
|
Django==5.2.7
|
||||||
mysqlclient==2.2.7
|
mysqlclient==2.2.7
|
||||||
python-dotenv==1.1.1
|
python-dotenv==1.1.1
|
||||||
|
reportlab==4.4.1
|
||||||
|
PyJWT==2.10.1
|
||||||
|
|||||||
@ -1,4 +1,539 @@
|
|||||||
/* Custom styles for the application */
|
:root {
|
||||||
body {
|
--momo-primary: #0f6f5c;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--momo-primary-dark: #0a5748;
|
||||||
|
--momo-secondary: #143642;
|
||||||
|
--momo-accent: #f59e0b;
|
||||||
|
--momo-coral: #ef6f6c;
|
||||||
|
--momo-bg: #f4f6f0;
|
||||||
|
--momo-surface: #ffffff;
|
||||||
|
--momo-surface-alt: #eef4ef;
|
||||||
|
--momo-border: #d4dfd6;
|
||||||
|
--momo-text: #17332f;
|
||||||
|
--momo-muted: #647874;
|
||||||
|
--momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%),
|
||||||
|
radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%),
|
||||||
|
linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%);
|
||||||
|
color: var(--momo-text);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm {
|
||||||
|
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-nav {
|
||||||
|
background: rgba(20, 54, 66, 0.82);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: linear-gradient(135deg, var(--momo-accent), #ffd279);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.78) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link:focus {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.8rem 1.4rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.btn:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-primary {
|
||||||
|
background: linear-gradient(135deg, var(--momo-primary), #16a085);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-primary:hover,
|
||||||
|
.btn-momo-primary:focus {
|
||||||
|
background: linear-gradient(135deg, #11806a, #18b18f);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.12);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-nav .btn-momo-ghost {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel,
|
||||||
|
.content-card,
|
||||||
|
.glass-card,
|
||||||
|
.soft-card,
|
||||||
|
.balance-card,
|
||||||
|
.mini-feature,
|
||||||
|
.metric-card,
|
||||||
|
.mini-stat {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.54);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: var(--momo-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
position: relative;
|
||||||
|
padding: 4.25rem clamp(1.4rem, 3vw, 3rem);
|
||||||
|
border-radius: calc(var(--radius-xl) + 8px);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)),
|
||||||
|
linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-title {
|
||||||
|
font-size: clamp(2.6rem, 4vw, 4.5rem);
|
||||||
|
line-height: 1.02;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.muted-copy {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.card-kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--momo-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-metrics .metric-card,
|
||||||
|
.mini-feature,
|
||||||
|
.soft-card,
|
||||||
|
.mini-stat {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span,
|
||||||
|
.mini-feature span,
|
||||||
|
.stack-item span,
|
||||||
|
.preview-metrics span,
|
||||||
|
.movement-card span,
|
||||||
|
.detail-grid span,
|
||||||
|
.rule-item span,
|
||||||
|
.report-preview-header span,
|
||||||
|
.mini-stat span {
|
||||||
|
display: block;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
color: #3e514d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-card {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(1.8rem, 2vw, 2.6rem);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-sm {
|
||||||
|
font-size: 1.32rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: clamp(1.35rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.35rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card span,
|
||||||
|
.mini-stat span {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card strong,
|
||||||
|
.mini-stat strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card small {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-primary {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-secondary {
|
||||||
|
background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-accent {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.22;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-one {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
right: -36px;
|
||||||
|
top: -28px;
|
||||||
|
background: linear-gradient(135deg, var(--momo-accent), #ffe1aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-two {
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
right: 14%;
|
||||||
|
bottom: -120px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.6rem 1.2rem;
|
||||||
|
border: 1px dashed var(--momo-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-border-color: rgba(20, 54, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-table thead th {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-badge {
|
||||||
|
background: rgba(15, 111, 92, 0.12);
|
||||||
|
color: var(--momo-primary-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--momo-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item,
|
||||||
|
.rule-item {
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
border: 1px solid var(--momo-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item strong,
|
||||||
|
.rule-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-form .form-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-input,
|
||||||
|
.momo-file {
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.14);
|
||||||
|
padding: 0.92rem 1rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-input:focus,
|
||||||
|
.momo-file:focus,
|
||||||
|
.btn:focus,
|
||||||
|
.nav-link:focus {
|
||||||
|
border-color: rgba(15, 111, 92, 0.5);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: #b4373b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini,
|
||||||
|
.report-preview-header,
|
||||||
|
.report-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini span,
|
||||||
|
.report-preview-header span {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-thumb,
|
||||||
|
.logo-placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.1);
|
||||||
|
background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16));
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics.two-up,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics div,
|
||||||
|
.detail-grid div,
|
||||||
|
.movement-card div,
|
||||||
|
.report-preview-card,
|
||||||
|
.mini-stat {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
border: 1px solid var(--momo-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics strong,
|
||||||
|
.detail-grid strong,
|
||||||
|
.movement-card strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sheet {
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro .muted-copy,
|
||||||
|
.content-card .muted-copy,
|
||||||
|
.auth-layout .muted-copy {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
min-height: 66vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .stack-item {
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.site-header,
|
||||||
|
.btn,
|
||||||
|
.message-stack,
|
||||||
|
.page-intro,
|
||||||
|
.report-shortcuts {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.content-card,
|
||||||
|
.report-sheet {
|
||||||
|
max-width: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.display-title {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini,
|
||||||
|
.report-preview-header,
|
||||||
|
.report-sheet-header,
|
||||||
|
.preview-metrics,
|
||||||
|
.preview-metrics.two-up,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.hero-panel {
|
||||||
|
padding: 2rem 1.2rem 2.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-title {
|
||||||
|
font-size: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card,
|
||||||
|
.balance-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,539 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--momo-primary: #0f6f5c;
|
||||||
--bg-color-end: #2575fc;
|
--momo-primary-dark: #0a5748;
|
||||||
--text-color: #ffffff;
|
--momo-secondary: #143642;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--momo-accent: #f59e0b;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--momo-coral: #ef6f6c;
|
||||||
|
--momo-bg: #f4f6f0;
|
||||||
|
--momo-surface: #ffffff;
|
||||||
|
--momo-surface-alt: #eef4ef;
|
||||||
|
--momo-border: #d4dfd6;
|
||||||
|
--momo-text: #17332f;
|
||||||
|
--momo-muted: #647874;
|
||||||
|
--momo-shadow: 0 24px 60px rgba(16, 58, 50, 0.12);
|
||||||
|
--radius-xl: 28px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--radius-md: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
background:
|
||||||
color: var(--text-color);
|
radial-gradient(circle at top left, rgba(245, 158, 11, 0.16), transparent 30%),
|
||||||
display: flex;
|
radial-gradient(circle at right top, rgba(15, 111, 92, 0.14), transparent 28%),
|
||||||
justify-content: center;
|
linear-gradient(180deg, #f7fbf8 0%, var(--momo-bg) 55%, #eef4ef 100%);
|
||||||
align-items: center;
|
color: var(--momo-text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-align: center;
|
}
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
h1, h2, h3, h4, .navbar-brand, .display-title, .section-title, .section-title-sm {
|
||||||
|
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-nav {
|
||||||
|
background: rgba(20, 54, 66, 0.82);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-badge {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: linear-gradient(135deg, var(--momo-accent), #ffd279);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 16px 30px rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
font-size: 1.12rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: rgba(255, 255, 255, 0.78) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link:focus {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.8rem 1.4rem;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover,
|
||||||
|
.btn:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-primary {
|
||||||
|
background: linear-gradient(135deg, var(--momo-primary), #16a085);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 16px 36px rgba(15, 111, 92, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-primary:hover,
|
||||||
|
.btn-momo-primary:focus {
|
||||||
|
background: linear-gradient(135deg, #11806a, #18b18f);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-momo-ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.12);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-nav .btn-momo-ghost {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel,
|
||||||
|
.content-card,
|
||||||
|
.glass-card,
|
||||||
|
.soft-card,
|
||||||
|
.balance-card,
|
||||||
|
.mini-feature,
|
||||||
|
.metric-card,
|
||||||
|
.mini-stat {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.54);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: var(--momo-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
position: relative;
|
||||||
|
padding: 4.25rem clamp(1.4rem, 3vw, 3rem);
|
||||||
|
border-radius: calc(var(--radius-xl) + 8px);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(20, 54, 66, 0.95), rgba(15, 111, 92, 0.84)),
|
||||||
|
linear-gradient(120deg, rgba(245, 158, 11, 0.2), transparent 42%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-title {
|
||||||
|
font-size: clamp(2.6rem, 4vw, 4.5rem);
|
||||||
|
line-height: 1.02;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
max-width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.muted-copy {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.card-kicker {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--momo-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-metrics .metric-card,
|
||||||
|
.mini-feature,
|
||||||
|
.soft-card,
|
||||||
|
.mini-stat {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span,
|
||||||
|
.mini-feature span,
|
||||||
|
.stack-item span,
|
||||||
|
.preview-metrics span,
|
||||||
|
.movement-card span,
|
||||||
|
.detail-grid span,
|
||||||
|
.rule-item span,
|
||||||
|
.report-preview-header span,
|
||||||
|
.mini-stat span {
|
||||||
|
display: block;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-card span {
|
||||||
|
color: #3e514d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.soft-card {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(1.8rem, 2vw, 2.6rem);
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title-sm {
|
||||||
|
font-size: 1.32rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: clamp(1.35rem, 3vw, 2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card {
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1.35rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card span,
|
||||||
|
.mini-stat span {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card strong,
|
||||||
|
.mini-stat strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.9rem;
|
||||||
|
margin: 0.45rem 0;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card small {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-primary {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 111, 92, 0.14), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-secondary {
|
||||||
|
background: linear-gradient(135deg, rgba(20, 54, 66, 0.12), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-card-accent {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(255, 255, 255, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.22;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-one {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
right: -36px;
|
||||||
|
top: -28px;
|
||||||
|
background: linear-gradient(135deg, var(--momo-accent), #ffe1aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-two {
|
||||||
|
width: 260px;
|
||||||
|
height: 260px;
|
||||||
|
right: 14%;
|
||||||
|
bottom: -120px;
|
||||||
|
background: linear-gradient(135deg, rgba(255,255,255,0.45), rgba(255,255,255,0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.6rem 1.2rem;
|
||||||
|
border: 1px dashed var(--momo-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-table {
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-border-color: rgba(20, 54, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-table thead th {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-badge {
|
||||||
|
background: rgba(15, 111, 92, 0.12);
|
||||||
|
color: var(--momo-primary-dark);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-link {
|
||||||
|
color: var(--momo-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item,
|
||||||
|
.rule-item {
|
||||||
|
padding: 1rem 1.05rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
border: 1px solid var(--momo-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack-item strong,
|
||||||
|
.rule-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-form .form-label {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-input,
|
||||||
|
.momo-file {
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.14);
|
||||||
|
padding: 0.92rem 1rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.momo-input:focus,
|
||||||
|
.momo-file:focus,
|
||||||
|
.btn:focus,
|
||||||
|
.nav-link:focus {
|
||||||
|
border-color: rgba(15, 111, 92, 0.5);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 111, 92, 0.12) !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-help {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: var(--momo-muted);
|
||||||
|
font-size: 0.87rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
color: #b4373b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini,
|
||||||
|
.report-preview-header,
|
||||||
|
.report-sheet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini span,
|
||||||
|
.report-preview-header span {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-thumb,
|
||||||
|
.logo-placeholder {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.1);
|
||||||
|
background: linear-gradient(135deg, rgba(15,111,92,0.14), rgba(245,158,11,0.16));
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics.two-up,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics div,
|
||||||
|
.detail-grid div,
|
||||||
|
.movement-card div,
|
||||||
|
.report-preview-card,
|
||||||
|
.mini-stat {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--momo-surface-alt);
|
||||||
|
border: 1px solid var(--momo-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-metrics strong,
|
||||||
|
.detail-grid strong,
|
||||||
|
.movement-card strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--momo-secondary);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-sheet {
|
||||||
|
border: 1px solid rgba(20, 54, 66, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-stat {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-intro .muted-copy,
|
||||||
|
.content-card .muted-copy,
|
||||||
|
.auth-layout .muted-copy {
|
||||||
|
color: var(--momo-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout {
|
||||||
|
min-height: 66vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .stack-item {
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.site-header,
|
||||||
|
.btn,
|
||||||
|
.message-stack,
|
||||||
|
.page-intro,
|
||||||
|
.report-shortcuts {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container,
|
||||||
|
.content-card,
|
||||||
|
.report-sheet {
|
||||||
|
max-width: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.display-title {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-mini,
|
||||||
|
.report-preview-header,
|
||||||
|
.report-sheet-header,
|
||||||
|
.preview-metrics,
|
||||||
|
.preview-metrics.two-up,
|
||||||
|
.detail-grid,
|
||||||
|
.movement-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.hero-panel {
|
||||||
|
padding: 2rem 1.2rem 2.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-title {
|
||||||
|
font-size: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card,
|
||||||
|
.balance-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user