diff --git a/Backend/controller/auth/handler.go b/Backend/controller/auth/handler.go index 17a81e1..2c8ae4a 100644 --- a/Backend/controller/auth/handler.go +++ b/Backend/controller/auth/handler.go @@ -4,6 +4,7 @@ import ( "net/http" "os" "personal-erp-backend/database" + "personal-erp-backend/internal/profile" "time" "github.com/gin-gonic/gin" @@ -15,9 +16,14 @@ var SecretKey = []byte(os.Getenv("SECRET_KEY")) func Register(c *gin.Context) { var input struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + PhoneNumber string `json:"phone_number"` + Country string `json:"country"` + TeleUsername string `json:"tele_username"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -28,25 +34,40 @@ func Register(c *gin.Context) { c.JSON(500, gin.H{"error": err.Error()}) return } - user := User{Username: input.Username, Email: input.Email, Password: string(hashedPassword)} + user := profile.User{ + Username: input.Username, + Email: input.Email, + Password: string(hashedPassword), + FirstName: input.FirstName, + LastName: input.LastName, + PhoneNumber: input.PhoneNumber, + Country: input.Country, + TeleUsername: input.TeleUsername, + } if err := database.DB.Create(&user).Error; err != nil { c.JSON(500, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"user": user}) + + if err := Login; err != nil { + c.JSON(500, gin.H{"error": err}) + return + } } func Login(c *gin.Context) { var input struct { Username string `json:"username"` + Email string `json:"email"` Password string `json:"password"` } if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - var user User - if err := database.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { + var user profile.User + if err := database.DB.Where("username = ? OR email = ?", input.Username, input.Email).First(&user).Error; err != nil { c.JSON(404, gin.H{"error": err.Error()}) return } @@ -66,6 +87,12 @@ func Login(c *gin.Context) { c.SetSameSite(http.SameSiteLaxMode) c.SetCookie("authorization", tokenString, 3600*24, "", "", false, true) c.JSON(http.StatusOK, gin.H{"token": tokenString, "message": "success"}) + + if err := profile.GetUser; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"message": "OK", "user": user}) } func Logout(c *gin.Context) { diff --git a/Backend/controller/auth/middleware.go b/Backend/controller/auth/middleware.go index faa200a..c1c73f4 100644 --- a/Backend/controller/auth/middleware.go +++ b/Backend/controller/auth/middleware.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "personal-erp-backend/database" + "personal-erp-backend/internal/profile" "time" "github.com/gin-gonic/gin" @@ -42,7 +43,7 @@ func RequireAuth(c *gin.Context) { if float64(time.Now().Unix()) > claims["exp"].(float64) { c.JSON(401, gin.H{"message": "Token expired"}) } - var user User + var user profile.User if result := database.DB.First(&user, claims["sub"]); result.Error != nil { c.AbortWithStatusJSON(401, gin.H{"error": "User no"}) return diff --git a/Backend/controller/auth/modelDB.go b/Backend/controller/auth/modelDB.go deleted file mode 100644 index dd18fcb..0000000 --- a/Backend/controller/auth/modelDB.go +++ /dev/null @@ -1,8 +0,0 @@ -package auth - -type User struct { - ID uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` - Username string `gorm:"size:255;not null" json:"username"` - Email string `gorm:"size:255;not null" json:"email"` - Password string `json:"-"` -} diff --git a/Backend/internal/profile/handler.go b/Backend/internal/profile/handler.go new file mode 100644 index 0000000..48b727e --- /dev/null +++ b/Backend/internal/profile/handler.go @@ -0,0 +1,35 @@ +package profile + +import ( + "personal-erp-backend/database" + + "github.com/gin-gonic/gin" +) + +func GetUser(c *gin.Context) { + var user User + userID, _ := c.Get("userID") + if err := database.DB.Where("id = ?", userID).Find(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"Success": user}) +} + +func UpdateUser(c *gin.Context) { + var user User + userID, _ := c.Get("userID") + if err := database.DB.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + if err := c.ShouldBind(&user); err != nil { + c.JSON(400, gin.H{"message": err}) + return + } + if err := database.DB.Model(&user).Updates(&user).Error; err != nil { + c.JSON(404, gin.H{"message": err}) + return + } + c.JSON(200, gin.H{"Success": user}) +} diff --git a/Backend/internal/profile/modelDB.go b/Backend/internal/profile/modelDB.go new file mode 100644 index 0000000..bf8b0ce --- /dev/null +++ b/Backend/internal/profile/modelDB.go @@ -0,0 +1,13 @@ +package profile + +type User struct { + ID uint `gorm:"primary_key;AUTO_INCREMENT" json:"id"` + Username string `gorm:"size:255;not null" json:"username"` + Email string `gorm:"size:255;not null" json:"email"` + Password string `json:"-"` + FirstName string `gorm:"size:255" json:"first_name"` + LastName string `gorm:"size:255" json:"last_name"` + PhoneNumber string `gorm:"size:255" json:"phone_number"` + Country string `gorm:"size:255" json:"country"` + TeleUsername string `gorm:"size:255" json:"tele_username"` +} diff --git a/Backend/internal/profile/router.go b/Backend/internal/profile/router.go new file mode 100644 index 0000000..3e7ae6b --- /dev/null +++ b/Backend/internal/profile/router.go @@ -0,0 +1,11 @@ +package profile + +import "github.com/gin-gonic/gin" + +func RegisterRouter(r *gin.RouterGroup) { + profile := r.Group("/profile") + { + profile.GET("/user", GetUser) + profile.POST("/user", UpdateUser) + } +} diff --git a/Backend/main.go b/Backend/main.go index 632515b..87019e8 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -6,6 +6,7 @@ import ( "personal-erp-backend/database" "personal-erp-backend/internal/academic" "personal-erp-backend/internal/finance" + "personal-erp-backend/internal/profile" "time" "github.com/gin-contrib/cors" @@ -16,7 +17,7 @@ func main() { database.ConnectDB() err := database.DB.AutoMigrate( - &auth2.User{}, + &profile.User{}, &academic.Subject{}, &academic.Task{}, &academic.Note{}, @@ -49,6 +50,7 @@ func main() { protected := r.Group("/api") protected.Use(auth2.RequireAuth) { + profile.RegisterRouter(protected) academic.RegisterRoutes(protected) finance.RegisterRouter(protected) } diff --git a/Frontend/project-CL/src/components/Button.tsx b/Frontend/project-CL/src/components/Button.tsx new file mode 100644 index 0000000..21e05be --- /dev/null +++ b/Frontend/project-CL/src/components/Button.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + isLoading?: boolean; + icon?: React.ReactNode; +} + +export default function Button({ + children, + variant = 'primary', + isLoading = false, + icon, + className = '', + disabled, + ...props +}: ButtonProps) { + const baseStyles = "inline-flex items-center justify-center -gap-1 rounded-xl text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]"; + + const variants = { + primary: "bg-indigo-600 text-white hover:bg-indigo-700 hover:shadow-lg hover:shadow-indigo-200 focus:ring-indigo-500 border border-transparent", + secondary: "bg-white text-slate-700 border border-slate-200 hover:bg-slate-50 hover:border-slate-300 focus:ring-slate-200 shadow-sm", + danger: "bg-red-50 text-red-600 hover:bg-red-100 border border-transparent focus:ring-red-500", + ghost: "bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-transparent", + }; + + return ( + + ); +} diff --git a/Frontend/project-CL/src/components/Input.tsx b/Frontend/project-CL/src/components/Input.tsx new file mode 100644 index 0000000..251f1f4 --- /dev/null +++ b/Frontend/project-CL/src/components/Input.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + icon?: React.ReactNode; +} + +export default function Input({ label, error, icon, className = '', ...props }: InputProps) { + return ( +
+ {label && ( + + )} +
+ {icon && ( +
+ {icon} +
+ )} + +
+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/Frontend/project-CL/src/components/Sidebar.tsx b/Frontend/project-CL/src/components/Sidebar.tsx index 14a653c..d924b5f 100644 --- a/Frontend/project-CL/src/components/Sidebar.tsx +++ b/Frontend/project-CL/src/components/Sidebar.tsx @@ -1,8 +1,7 @@ import { NavLink } from "react-router"; -import { useAuth } from "../features/auth/context/AuthContext"; + export default function Sidebar() { - const { logout } = useAuth(); const links = [ { name: "Home", path: "/", icon: "🏠" }, { name: "Academic", path: "/academic", icon: "🎓" }, @@ -36,13 +35,18 @@ export default function Sidebar() { ))}
- + 👤 + Profile +
diff --git a/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts b/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts new file mode 100644 index 0000000..5f72b14 --- /dev/null +++ b/Frontend/project-CL/src/features/academic/hooks/useAcademicDashboard.ts @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react'; +import { academicService, type Subject, type Task } from '../services/academicService'; + +export interface AcademicMetrics { + gpa: number; + totalSks: number; + pendingTasks: number; + upcomingDeadlines: Task[]; +} + +export const useAcademicDashboard = () => { + const [subjects, setSubjects] = useState([]); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [metrics, setMetrics] = useState({ + gpa: 0, + totalSks: 0, + pendingTasks: 0, + upcomingDeadlines: [] + }); + + const refreshData = async () => { + setLoading(true); + try { + const [subjectData, taskData] = await Promise.all([ + academicService.getSubjects(), + academicService.getTasks() + ]); + + setSubjects(subjectData); + setTasks(taskData); + + calculateMetrics(subjectData, taskData); + } catch (err) { + console.error("Failed to fetch academic data", err); + setError("Failed to load academic data"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + refreshData(); + }, []); + + const calculateMetrics = (subs: Subject[], tasks: Task[]) => { + // Calculate GPA + let totalPoints = 0; + let totalSks = 0; + + subs.forEach(sub => { + const sks = parseInt(sub.sks) || 0; + const points = getGradePoints(sub.grade); + + // Only count if grade is valid (not empty/in-progress if applicable) + // Assuming empty grade means in progress, maybe don't count? + // For now, if we have a grade, we count it. + if (sub.grade && sks > 0) { + totalPoints += points * sks; + totalSks += sks; + } + }); + + const gpa = totalSks > 0 ? totalPoints / totalSks : 0; + + // Calculate Pending Tasks + const pending = tasks.filter(t => t.status !== 'Completed' && t.status !== 'Done'); + + // Get upcoming deadlines (next 7 days) + const now = new Date(); + const nextWeek = new Date(); + nextWeek.setDate(now.getDate() + 7); + + const upcoming = pending.filter(t => { + const deadline = new Date(t.deadline); + return deadline >= now && deadline <= nextWeek; + }).sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime()); + + setMetrics({ + gpa, + totalSks, + pendingTasks: pending.length, + upcomingDeadlines: upcoming + }); + }; + + const getGradePoints = (grade: string): number => { + switch (grade.toUpperCase()) { + case 'A': return 4.0; + case 'A-': return 3.7; + case 'B+': return 3.3; + case 'B': return 3.0; + case 'B-': return 2.7; + case 'C+': return 2.3; + case 'C': return 2.0; + case 'D': return 1.0; + default: return 0.0; + } + }; + + return { metrics, loading, error, subjects, tasks, refreshData }; +}; diff --git a/Frontend/project-CL/src/features/auth/components/LoginForm.tsx b/Frontend/project-CL/src/features/auth/components/LoginForm.tsx index 947ca6b..5023080 100644 --- a/Frontend/project-CL/src/features/auth/components/LoginForm.tsx +++ b/Frontend/project-CL/src/features/auth/components/LoginForm.tsx @@ -13,7 +13,7 @@ export function LoginForm({ onRegisterClick }: LoginFormProps) { return (
+
+ + +
+ + + +
+ + +
+ + + { setError(null); const formData = new FormData(e.currentTarget); - const data = Object.fromEntries(formData); + const rawData = Object.fromEntries(formData); + + // Backend expects username OR email. We send the same value for both + // so the backend query (username = ? || email = ?) works for either. + const data = { + ...rawData, + email: rawData.username // Duplicate username to email + }; try { const response = await authService.login(data); diff --git a/Frontend/project-CL/src/features/profile/services/profileService.ts b/Frontend/project-CL/src/features/profile/services/profileService.ts new file mode 100644 index 0000000..1851dc2 --- /dev/null +++ b/Frontend/project-CL/src/features/profile/services/profileService.ts @@ -0,0 +1,23 @@ +import api from '../../../services/api'; + +export interface UserProfile { + id: number; + username: string; + email: string; + first_name: string; + last_name: string; + phone_number: string; + country: string; + tele_username: string; +} + +export const profileService = { + getUser: async () => { + const response = await api.get<{ Success: UserProfile }>('/profile/user'); + return response.data.Success; + }, + updateUser: async (data: Partial) => { + const response = await api.post<{ Success: UserProfile }>('/profile/user', data); + return response.data.Success; + } +}; diff --git a/Frontend/project-CL/src/main.tsx b/Frontend/project-CL/src/main.tsx index ea21ba4..4f15ccd 100644 --- a/Frontend/project-CL/src/main.tsx +++ b/Frontend/project-CL/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import { RouterProvider } from 'react-router' import Home from './pages/home' +import Profile from './pages/profile' import { createBrowserRouter } from 'react-router' import Academic from './features/academic/academic' import SidebarLayout from './layouts/SidebarLayout' @@ -33,6 +34,10 @@ const router = createBrowserRouter([ path: '/finance', element: , }, + { + path: '/profile', + element: , + }, ] } ] diff --git a/Frontend/project-CL/src/pages/home.tsx b/Frontend/project-CL/src/pages/home.tsx index b2055a5..11d7b3a 100644 --- a/Frontend/project-CL/src/pages/home.tsx +++ b/Frontend/project-CL/src/pages/home.tsx @@ -1,8 +1,22 @@ import Header from "../components/Header"; import ModuleCard from "../components/ModuleCard"; import Card from "../components/Card"; +import { useFinancialDashboard } from "../features/financial/hooks/useFinancialDashboard"; +import { useAcademicDashboard } from "../features/academic/hooks/useAcademicDashboard"; export default function Home() { + const { metrics: finMetrics, loading: finLoading } = useFinancialDashboard(); + const { metrics: acadMetrics, loading: acadLoading } = useAcademicDashboard(); + + const formatCurrency = (val: number) => { + if (Math.abs(val) >= 1_000_000_000) { + return `Rp ${(val / 1_000_000_000).toFixed(1)}B`; + } else if (Math.abs(val) >= 1_000_000) { + return `Rp ${(val / 1_000_000).toFixed(1)}M`; + } + return `Rp ${(val / 1000).toFixed(0)}k`; + }; + return (
@@ -12,18 +26,30 @@ export default function Home() {
Total Assets
-
RP 12.5M
-
+2.5% this month
+
+ {finLoading ? "Loading..." : formatCurrency(finMetrics.balance)} +
+
+ {finLoading ? "..." : (finMetrics.totalIncome > 0 ? "Active" : "No Income")} +
Pending Tasks
-
5
-
3 High Priority
+
+ {acadLoading ? "..." : acadMetrics.pendingTasks} +
+
+ {acadLoading ? "..." : `${acadMetrics.upcomingDeadlines.length} Upcoming`} +
Academic Status
-
3.8 GPA
-
Excellent
+
+ {acadLoading ? "..." : `${acadMetrics.gpa.toFixed(2)} GPA`} +
+
+ {acadLoading ? "..." : `${acadMetrics.totalSks} SKS Completed`} +
@@ -56,31 +82,19 @@ export default function Home() { } color="bg-emerald-50 text-emerald-600" /> - - - - } - color="bg-amber-50 text-amber-600" - /> - - + + {/* More Modules Placeholder */} +
+
+ + - } - color="bg-rose-50 text-rose-600" - /> +
+ More modules to come +
) -} \ No newline at end of file +} diff --git a/Frontend/project-CL/src/pages/profile.tsx b/Frontend/project-CL/src/pages/profile.tsx new file mode 100644 index 0000000..a349c0b --- /dev/null +++ b/Frontend/project-CL/src/pages/profile.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from 'react'; +import { User, Mail, Shield, Camera, Save, Phone, MapPin, Send } from 'lucide-react'; +import Card from '../components/Card'; +import Input from '../components/Input'; +import Button from '../components/Button'; +import { useAuth } from '../features/auth/context/AuthContext'; +import { LogOut } from 'lucide-react'; +import { profileService } from '../features/profile/services/profileService'; + +export default function Profile() { + const { logout } = useAuth(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState({ + username: '', + email: '', + first_name: '', + last_name: '', + phone_number: '', + country: '', + tele_username: '', + currentPassword: '', + newPassword: '', + confirmPassword: '', + }); + + useEffect(() => { + const fetchUser = async () => { + try { + const user = await profileService.getUser(); + setFormData(prev => ({ + ...prev, + username: user.username, + email: user.email, + first_name: user.first_name || '', + last_name: user.last_name || '', + phone_number: user.phone_number || '', + country: user.country || '', + tele_username: user.tele_username || '', + })); + } catch (error) { + console.error("Failed to fetch user:", error); + } + }; + fetchUser(); + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + try { + await profileService.updateUser({ + first_name: formData.first_name, + last_name: formData.last_name, + phone_number: formData.phone_number, + country: formData.country, + tele_username: formData.tele_username, + }); + alert("Profile updated successfully"); + } catch (error) { + console.error("Failed to update profile:", error); + alert("Failed to update profile"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* Header */} +
+

Profile Settings

+

Manage your account settings and preferences.

+
+ +
+ {/* Left Column - Profile Card */} +
+ +
+
+ + {formData.username.charAt(0).toUpperCase()} + +
+
+ +
+
+

{formData.username}

+

Administrator

+
+ +
+
+
+ +
+ Personal Information +
+
+
+ +
+ Security & Privacy +
+ + +
+
+ + {/* Right Column - Edit Form */} +
+ + +
+
+

+ + Basic Information +

+
+ +
+ + +
+ +
+ } + /> + } + /> +
+ +
+ +
+ } + readOnly + className="bg-slate-50" + /> + } + /> +
+
+
+ +
+
+

+ + Security +

+
+ +
+ +
+ + +
+
+
+ +
+ + +
+ +
+
+
+
+ ); +}