diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/ai/__init__.py b/ai/__init__.py new file mode 100644 index 0000000..37a7b09 --- /dev/null +++ b/ai/__init__.py @@ -0,0 +1,3 @@ +"""Helpers for interacting with the Flatlogic AI proxy from Django code.""" + +from .local_ai_api import LocalAIApi, create_response, request, decode_json_from_response # noqa: F401 diff --git a/ai/local_ai_api.py b/ai/local_ai_api.py new file mode 100644 index 0000000..bc1af27 --- /dev/null +++ b/ai/local_ai_api.py @@ -0,0 +1,282 @@ +""" +LocalAIApi — lightweight Python client for the Flatlogic AI proxy. + +Usage (inside the Django workspace): + + from ai.local_ai_api import LocalAIApi + + response = LocalAIApi.create_response({ + "input": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Summarise this text in two sentences."}, + ], + "text": {"format": {"type": "json_object"}}, + }) + + if response.get("success"): + data = LocalAIApi.decode_json_from_response(response) + # ... + +The helper automatically injects the project UUID header and falls back to +reading executor/.env if environment variables are missing. +""" + +from __future__ import annotations + +import json +import os +import ssl +from typing import Any, Dict, Iterable, Optional +from urllib import error as urlerror +from urllib import request as urlrequest + +__all__ = [ + "LocalAIApi", + "create_response", + "request", + "decode_json_from_response", +] + + +_CONFIG_CACHE: Optional[Dict[str, Any]] = None + + +class LocalAIApi: + """Static helpers mirroring the PHP implementation.""" + + @staticmethod + def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return create_response(params, options or {}) + + @staticmethod + def request(path: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, + options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return request(path, payload or {}, options or {}) + + @staticmethod + def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return decode_json_from_response(response) + + +def create_response(params: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Signature compatible with the OpenAI Responses API.""" + options = options or {} + payload = dict(params) + + if not isinstance(payload.get("input"), list) or not payload["input"]: + return { + "success": False, + "error": "input_missing", + "message": 'Parameter "input" is required and must be a non-empty list.', + } + + cfg = _config() + if not payload.get("model"): + payload["model"] = cfg["default_model"] + + return request(options.get("path"), payload, options) + + +def request(path: Optional[str], payload: Dict[str, Any], options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Perform a raw request to the AI proxy.""" + cfg = _config() + options = options or {} + + resolved_path = path or options.get("path") or cfg["responses_path"] + if not resolved_path: + return { + "success": False, + "error": "project_id_missing", + "message": "PROJECT_ID is not defined; cannot resolve AI proxy endpoint.", + } + + project_uuid = cfg["project_uuid"] + if not project_uuid: + return { + "success": False, + "error": "project_uuid_missing", + "message": "PROJECT_UUID is not defined; aborting AI request.", + } + + if "project_uuid" not in payload and project_uuid: + payload["project_uuid"] = project_uuid + + url = _build_url(resolved_path, cfg["base_url"]) + timeout = int(options.get("timeout", cfg["timeout"])) + verify_tls = options.get("verify_tls", cfg["verify_tls"]) + + headers: Dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + cfg["project_header"]: project_uuid, + } + extra_headers = options.get("headers") + if isinstance(extra_headers, Iterable): + for header in extra_headers: + if isinstance(header, str) and ":" in header: + name, value = header.split(":", 1) + headers[name.strip()] = value.strip() + + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urlrequest.Request(url, data=body, method="POST") + for name, value in headers.items(): + req.add_header(name, value) + + context = None + if not verify_tls: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + try: + with urlrequest.urlopen(req, timeout=timeout, context=context) as resp: + status = resp.getcode() + response_body = resp.read().decode("utf-8", errors="replace") + except urlerror.HTTPError as exc: + status = exc.getcode() + response_body = exc.read().decode("utf-8", errors="replace") + except Exception as exc: # pylint: disable=broad-except + return { + "success": False, + "error": "request_failed", + "message": str(exc), + } + + decoded = None + if response_body: + try: + decoded = json.loads(response_body) + except json.JSONDecodeError: + decoded = None + + if 200 <= status < 300: + return { + "success": True, + "status": status, + "data": decoded if decoded is not None else response_body, + } + + error_message = "AI proxy request failed" + if isinstance(decoded, dict): + error_message = decoded.get("error") or decoded.get("message") or error_message + elif response_body: + error_message = response_body + + return { + "success": False, + "status": status, + "error": error_message, + "response": decoded if decoded is not None else response_body, + } + + +def decode_json_from_response(response: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Attempt to decode JSON emitted by the model (handles markdown fences).""" + text = _extract_text(response) + if text == "": + return None + + try: + decoded = json.loads(text) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + pass + + stripped = text.strip() + if stripped.startswith("```json"): + stripped = stripped[7:] + if stripped.endswith("```"): + stripped = stripped[:-3] + stripped = stripped.strip() + if stripped and stripped != text: + try: + decoded = json.loads(stripped) + if isinstance(decoded, dict): + return decoded + except json.JSONDecodeError: + return None + return None + + +def _extract_text(response: Dict[str, Any]) -> str: + payload = response.get("data") if response.get("success") else response.get("response") + if isinstance(payload, dict): + output = payload.get("output") + if isinstance(output, list): + combined = "" + for item in output: + content = item.get("content") if isinstance(item, dict) else None + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "output_text" and block.get("text"): + combined += str(block["text"]) + if combined: + return combined + choices = payload.get("choices") + if isinstance(choices, list) and choices: + message = choices[0].get("message") + if isinstance(message, dict) and message.get("content"): + return str(message["content"]) + if isinstance(payload, str): + return payload + return "" + + +def _config() -> Dict[str, Any]: + global _CONFIG_CACHE # noqa: PLW0603 + if _CONFIG_CACHE is not None: + return _CONFIG_CACHE + + _ensure_env_loaded() + + base_url = os.getenv("AI_PROXY_BASE_URL", "https://flatlogic.com") + project_id = os.getenv("PROJECT_ID") or None + responses_path = os.getenv("AI_RESPONSES_PATH") + if not responses_path and project_id: + responses_path = f"/projects/{project_id}/ai-request" + + _CONFIG_CACHE = { + "base_url": base_url, + "responses_path": responses_path, + "project_id": project_id, + "project_uuid": os.getenv("PROJECT_UUID"), + "project_header": os.getenv("AI_PROJECT_HEADER", "project-uuid"), + "default_model": os.getenv("AI_DEFAULT_MODEL", "gpt-5"), + "timeout": int(os.getenv("AI_TIMEOUT", "30")), + "verify_tls": os.getenv("AI_VERIFY_TLS", "true").lower() not in {"0", "false", "no"}, + } + return _CONFIG_CACHE + + +def _build_url(path: str, base_url: str) -> str: + trimmed = path.strip() + if trimmed.startswith("http://") or trimmed.startswith("https://"): + return trimmed + if trimmed.startswith("/"): + return f"{base_url}{trimmed}" + return f"{base_url}/{trimmed}" + + +def _ensure_env_loaded() -> None: + """Populate os.environ from executor/.env if variables are missing.""" + if os.getenv("PROJECT_UUID") and os.getenv("PROJECT_ID"): + return + + env_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".env")) + if not os.path.exists(env_path): + return + + try: + with open(env_path, "r", encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + key = key.strip() + value = value.strip().strip('\'"') + if key and not os.getenv(key): + os.environ[key] = value + except OSError: + pass diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 3d6501c..7ee6bdb 100644 Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index dadfaa7..47d9e4f 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 139db10..f6861d2 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index 79ce690..24749b2 100644 Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc index 3b7774e..c31d318 100644 Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e41572..a781e76 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc index 6435d92..c70fd57 100644 Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index f6e5c4e..1f6b068 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 5b41fe1..5a122ed 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 4e4f113..8f06ecb 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 9d0ddd8..b823176 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc index 64d8a55..4db0e32 100644 Binary files a/core/migrations/__pycache__/0001_initial.cpython-311.pyc and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc index 58b1c14..3b2886b 100644 Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/templates/base.html b/core/templates/base.html new file mode 100644 index 0000000..147fe52 --- /dev/null +++ b/core/templates/base.html @@ -0,0 +1,18 @@ +{% load static %} + + +
+ +Published on {{ article.created_at|date:"F d, Y" }}
+Halaman ini dalam pengembangan.
+{% endblock %} diff --git a/core/templates/core/create_pppoe_profile.html b/core/templates/core/create_pppoe_profile.html new file mode 100644 index 0000000..c50ad5c --- /dev/null +++ b/core/templates/core/create_pppoe_profile.html @@ -0,0 +1,8 @@ +{% extends "core/dashboard_base.html" %} + +{% block dashboard_content %} + +Halaman ini dalam pengembangan.
+{% endblock %} diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html new file mode 100644 index 0000000..387bee1 --- /dev/null +++ b/core/templates/core/dashboard.html @@ -0,0 +1,62 @@ +{% extends "core/dashboard_base.html" %} + +{% block dashboard_content %} + + +{% if error %} +{{ total_customers }}
+{{ total_hotspot }}
+{{ total_pppoe }}
+| ID | +Nama User | +Mac Address | +Tipe Koneksi | +Paket/Profile | +Tanggal Kadaluarsa | +
|---|---|---|---|---|---|
| {{ forloop.counter }} | +{{ customer.name }} | +{{ customer.mac_address }} | +{{ customer.type }} | +{{ customer.profile }} | +{{ customer.comment }} | +
| Tidak ada data pelanggan yang ditemukan. | +|||||
Halaman ini dalam pengembangan.
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index f4e4991..6997d2e 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,157 +1,36 @@ - - +{% extends "base.html" %} - - - -Kelola data pelanggan, status langganan, dan riwayat pembayaran dengan mudah.
Appwizzy AI is collecting your requirements and applying the first changes.
-This page will refresh automatically as the plan is implemented.
-
- Runtime: Django {{ django_version }} · Python {{ python_version }} —
- UTC {{ current_time|date:"Y-m-d H:i:s" }}
-
Monitor dan kelola traffic, bandwidth, dan koneksi pengguna secara real-time.
+Dapatkan wawasan tentang penggunaan jaringan dan pendapatan melalui laporan terperinci.
+Halaman ini dalam pengembangan.
+{% endblock %} diff --git a/core/templates/core/login.html b/core/templates/core/login.html new file mode 100644 index 0000000..7897f4a --- /dev/null +++ b/core/templates/core/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Login - KapiohoNet{% endblock %} + +{% block content %} +Akses On-Demand
+ +Halaman ini dalam pengembangan.
+{% endblock %} diff --git a/core/urls.py b/core/urls.py index 6299e3d..d203b1d 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,17 @@ from django.urls import path - -from .views import home +from .views import ( + login_view, dashboard_view, logout_view, + create_pppoe_profile_view, input_pppoe_customer_view, create_hotspot_voucher_view, + edit_route_view, monthly_report_view +) urlpatterns = [ - path("", home, name="home"), + path('login/', login_view, name='login'), + path('dashboard/', dashboard_view, name='dashboard'), + path('logout/', logout_view, name='logout'), + path('create-pppoe-profile/', create_pppoe_profile_view, name='create_pppoe_profile'), + path('input-pppoe-customer/', input_pppoe_customer_view, name='input_pppoe_customer'), + path('create-hotspot-voucher/', create_hotspot_voucher_view, name='create_hotspot_voucher'), + path('edit-route/', edit_route_view, name='edit_route'), + path('monthly-report/', monthly_report_view, name='monthly_report'), ] diff --git a/core/views.py b/core/views.py index c1a6d45..4fe87ee 100644 --- a/core/views.py +++ b/core/views.py @@ -1,37 +1,135 @@ -import os -import platform +import routeros_api +from django.shortcuts import render, redirect +from django.contrib.auth import login, logout +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User -from django import get_version as django_version -from django.shortcuts import render -from django.urls import reverse_lazy -from django.utils import timezone -from django.views.generic.edit import CreateView +# A dummy user is used to authenticate against Django's session management. +# This is not a real user in the database, but a temporary, in-memory user. +# This is required to use the @login_required decorator. +# We create the user if it does not exist. +try: + User.objects.get(username='dummy') +except User.DoesNotExist: + User.objects.create_user('dummy', password='dummy') -from .forms import TicketForm -from .models import Ticket +def login_view(request): + if request.method == 'POST': + router_ip = request.POST.get('router_ip') + username = request.POST.get('username') + password = request.POST.get('password') + try: + connection = routeros_api.RouterOsApiPool( + router_ip, + username=username, + password=password, + plaintext_login=True + ) + api = connection.get_api() + # If connection is successful, store credentials in session + request.session['router_ip'] = router_ip + request.session['username'] = username + request.session['password'] = password + + user = User.objects.get(username='dummy') + login(request, user) -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() + return redirect('dashboard') + except Exception as e: + # Handle connection error + return render(request, 'core/login.html', {'error': str(e)}) - context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "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) + return render(request, 'core/login.html') +@login_required(login_url='/') +def dashboard_view(request): + router_ip = request.session.get('router_ip') + username = request.session.get('username') + password = request.session.get('password') -class TicketCreateView(CreateView): - model = Ticket - form_class = TicketForm - template_name = "core/ticket_create.html" - success_url = reverse_lazy("home") + if not all([router_ip, username]): + return redirect('login') + + try: + connection = routeros_api.RouterOsApiPool( + router_ip, + username=username, + password=password, + plaintext_login=True + ) + api = connection.get_api() + + # Fetch PPPoE secrets + ppp_secrets = api.get_resource('/ppp/secret').get() + + # Fetch Hotspot users + hotspot_users = api.get_resource('/ip/hotspot/user').get() + + # Process data + customers = [] + for user in ppp_secrets: + customers.append({ + 'id': user.get('id'), + 'name': user.get('name'), + 'mac_address': user.get('caller-id', '-'), + 'type': 'PPPoE', + 'profile': user.get('profile', '-'), + 'expires': user.get('comment', '-') + }) + + for user in hotspot_users: + customers.append({ + 'id': user.get('id'), + 'name': user.get('name'), + 'mac_address': user.get('mac-address', '-'), + 'type': 'Hotspot', + 'profile': user.get('profile', '-'), + 'expires': user.get('comment', '-') + }) + + total_customers = len(customers) + total_hotspot = len(hotspot_users) + total_pppoe = len(ppp_secrets) + + context = { + 'customers': customers, + 'total_customers': total_customers, + 'total_hotspot': total_hotspot, + 'total_pppoe': total_pppoe, + } + + return render(request, 'core/dashboard.html', context) + + except Exception as e: + # On error, redirect to login and show error + return redirect('login') + +def logout_view(request): + logout(request) + return redirect('login') + +def create_pppoe_profile_view(request): + if not request.session.get('router_ip'): + return redirect('login') + return render(request, 'core/create_pppoe_profile.html') + +def input_pppoe_customer_view(request): + if not request.session.get('router_ip'): + return redirect('login') + return render(request, 'core/input_customer.html') + +def create_hotspot_voucher_view(request): + if not request.session.get('router_ip'): + return redirect('login') + return render(request, 'core/create_hotspot_voucher.html') + +def edit_route_view(request): + if not request.session.get('router_ip'): + return redirect('login') + return render(request, 'core/edit_route.html') + +def monthly_report_view(request): + if not request.session.get('router_ip'): + return redirect('login') + return render(request, 'core/monthly_report.html') diff --git a/requirements.txt b/requirements.txt index e22994c..8269b79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 +routeros-api \ No newline at end of file diff --git a/static/css/custom.css b/static/css/custom.css new file mode 100644 index 0000000..dd72a30 --- /dev/null +++ b/static/css/custom.css @@ -0,0 +1,101 @@ +/* General Body Styling */ +body { + background-color: #f8f9fa; + color: #212529; + font-family: 'Roboto', sans-serif; +} + +/* Login Page Styling */ +.login-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background-color: #eef2f7; +} + +.login-box { + width: 100%; + max-width: 400px; + padding: 40px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.login-box h2 { + font-family: 'Poppins', sans-serif; + font-weight: 700; +} + +.login-box .form-control { + height: 48px; + border-radius: 6px; +} + +.login-box .btn-primary { + background-color: #3b82f6; + border-color: #3b82f6; + padding: 12px; + font-weight: 600; +} + +.login-box .btn-primary:hover { + background-color: #2563eb; + border-color: #2563eb; +} + +/* Dashboard Layout */ +.sidebar { + width: 260px; + height: 100vh; + position: fixed; + top: 0; + left: 0; + background-color: #1f2937; + color: #ffffff; + padding-top: 20px; +} + +.sidebar-header { + padding: 0 20px 20px 20px; + border-bottom: 1px solid #374151; +} + +.sidebar-header h3 { + font-family: 'Poppins', sans-serif; + font-weight: 700; + margin: 0; +} + +.sidebar .nav-link { + color: #d1d5db; + padding: 12px 20px; + font-weight: 500; +} + +.sidebar .nav-link.active, +.sidebar .nav-link:hover { + color: #ffffff; + background-color: #374151; +} + +.main-content { + margin-left: 260px; + width: calc(100% - 260px); + padding: 20px; +} + +.header { + background: #ffffff; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.header h2 { + margin: 0; + font-family: 'Poppins', sans-serif; + font-weight: 600; +} \ No newline at end of file diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..dd72a30 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,101 @@ - -: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); -} +/* General Body Styling */ 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; + background-color: #f8f9fa; + color: #212529; + font-family: 'Roboto', sans-serif; } + +/* Login Page Styling */ +.login-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background-color: #eef2f7; +} + +.login-box { + width: 100%; + max-width: 400px; + padding: 40px; + background: #ffffff; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.login-box h2 { + font-family: 'Poppins', sans-serif; + font-weight: 700; +} + +.login-box .form-control { + height: 48px; + border-radius: 6px; +} + +.login-box .btn-primary { + background-color: #3b82f6; + border-color: #3b82f6; + padding: 12px; + font-weight: 600; +} + +.login-box .btn-primary:hover { + background-color: #2563eb; + border-color: #2563eb; +} + +/* Dashboard Layout */ +.sidebar { + width: 260px; + height: 100vh; + position: fixed; + top: 0; + left: 0; + background-color: #1f2937; + color: #ffffff; + padding-top: 20px; +} + +.sidebar-header { + padding: 0 20px 20px 20px; + border-bottom: 1px solid #374151; +} + +.sidebar-header h3 { + font-family: 'Poppins', sans-serif; + font-weight: 700; + margin: 0; +} + +.sidebar .nav-link { + color: #d1d5db; + padding: 12px 20px; + font-weight: 500; +} + +.sidebar .nav-link.active, +.sidebar .nav-link:hover { + color: #ffffff; + background-color: #374151; +} + +.main-content { + margin-left: 260px; + width: calc(100% - 260px); + padding: 20px; +} + +.header { + background: #ffffff; + padding: 20px; + border-radius: 8px; + margin-bottom: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.header h2 { + margin: 0; + font-family: 'Poppins', sans-serif; + font-weight: 600; +} \ No newline at end of file