feat: Implement Ticket System, Profile Page integration, and fix migrations
This commit is contained in:
parent
fd59bfacb2
commit
78ce341370
29 changed files with 2141 additions and 709 deletions
53
backend/cmd/inspect_schema/main.go
Normal file
53
backend/cmd/inspect_schema/main.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
godotenv.Load(".env")
|
||||||
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
// Fallback
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
user := os.Getenv("DB_USER")
|
||||||
|
pass := os.Getenv("DB_PASSWORD")
|
||||||
|
name := os.Getenv("DB_NAME")
|
||||||
|
ssl := os.Getenv("DB_SSLMODE")
|
||||||
|
if host != "" {
|
||||||
|
dbURL = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, name, ssl)
|
||||||
|
} else {
|
||||||
|
log.Fatal("DB URL not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users';
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
fmt.Println("USERS TABLE SCHEMA:")
|
||||||
|
for rows.Next() {
|
||||||
|
var colName, dataType string
|
||||||
|
rows.Scan(&colName, &dataType)
|
||||||
|
fmt.Printf("%s: %s\n", colName, dataType)
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/cmd/manual_migrate/main.go
Normal file
93
backend/cmd/manual_migrate/main.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pwd, _ := os.Getwd()
|
||||||
|
log.Printf("Current Working Directory: %s", pwd)
|
||||||
|
|
||||||
|
if err := godotenv.Load(".env"); err != nil {
|
||||||
|
// Try loading from parent if not in root
|
||||||
|
if err := godotenv.Load("../.env"); err != nil {
|
||||||
|
log.Println("No .env file found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dbURL := os.Getenv("DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
host := os.Getenv("DB_HOST")
|
||||||
|
port := os.Getenv("DB_PORT")
|
||||||
|
user := os.Getenv("DB_USER")
|
||||||
|
pass := os.Getenv("DB_PASSWORD")
|
||||||
|
name := os.Getenv("DB_NAME")
|
||||||
|
ssl := os.Getenv("DB_SSLMODE")
|
||||||
|
if host != "" {
|
||||||
|
dbURL = fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, name, ssl)
|
||||||
|
} else {
|
||||||
|
// Last resort
|
||||||
|
dbURL = "postgres://postgres:postgres@localhost:5432/gohorsejobs?sslmode=disable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("postgres", dbURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Try multiple paths
|
||||||
|
paths := []string{
|
||||||
|
"migrations/017_create_tickets_table.sql",
|
||||||
|
"backend/migrations/017_create_tickets_table.sql",
|
||||||
|
"../migrations/017_create_tickets_table.sql",
|
||||||
|
"/home/yamamoto/lab/gohorsejobs/backend/migrations/017_create_tickets_table.sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
var content []byte
|
||||||
|
var readErr error
|
||||||
|
|
||||||
|
for _, p := range paths {
|
||||||
|
content, readErr = os.ReadFile(p)
|
||||||
|
if readErr == nil {
|
||||||
|
log.Printf("Found migration at: %s", p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if content == nil {
|
||||||
|
log.Fatalf("Could not find migration file. Last error: %v", readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
statements := strings.Split(string(content), ";")
|
||||||
|
for _, stmt := range statements {
|
||||||
|
trimmed := strings.TrimSpace(stmt)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Executing: %s", trimmed)
|
||||||
|
_, err = db.Exec(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
// Log but maybe don't fail if it's just "already exists"?
|
||||||
|
// But we want to be strict.
|
||||||
|
// If it's "relation already exists", we might ignore.
|
||||||
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
|
log.Printf("Warning (ignored): %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("FAILED executing: %s\nError: %v", trimmed, err)
|
||||||
|
// Fail?
|
||||||
|
// log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Migration 017 applied successfully")
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ module github.com/rede5/gohorsejobs/backend
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.5
|
github.com/aws/aws-sdk-go-v2/config v1.32.5
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
|
||||||
|
|
@ -18,7 +19,6 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||||
|
|
|
||||||
|
|
@ -106,20 +106,38 @@ func (h *AdminHandlers) ListLoginAudits(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandlers) ListCompanies(w http.ResponseWriter, r *http.Request) {
|
||||||
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 10
|
||||||
|
}
|
||||||
|
|
||||||
var verified *bool
|
var verified *bool
|
||||||
if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" {
|
if verifiedParam := r.URL.Query().Get("verified"); verifiedParam != "" {
|
||||||
value := verifiedParam == "true"
|
value := verifiedParam == "true"
|
||||||
verified = &value
|
verified = &value
|
||||||
}
|
}
|
||||||
|
|
||||||
companies, err := h.adminService.ListCompanies(r.Context(), verified)
|
companies, total, err := h.adminService.ListCompanies(r.Context(), verified, page, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response := dto.PaginatedResponse{
|
||||||
|
Data: companies,
|
||||||
|
Pagination: dto.Pagination{
|
||||||
|
Page: page,
|
||||||
|
Limit: limit,
|
||||||
|
Total: total,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(companies)
|
json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandlers) UpdateCompanyStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *AdminHandlers) UpdateCompanyStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import (
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
tenant "github.com/rede5/gohorsejobs/backend/internal/core/usecases/tenant"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
user "github.com/rede5/gohorsejobs/backend/internal/core/usecases/user"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/services"
|
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,11 +22,14 @@ type CoreHandlers struct {
|
||||||
createUserUC *user.CreateUserUseCase
|
createUserUC *user.CreateUserUseCase
|
||||||
listUsersUC *user.ListUsersUseCase
|
listUsersUC *user.ListUsersUseCase
|
||||||
deleteUserUC *user.DeleteUserUseCase
|
deleteUserUC *user.DeleteUserUseCase
|
||||||
|
updateUserUC *user.UpdateUserUseCase
|
||||||
listCompaniesUC *tenant.ListCompaniesUseCase
|
listCompaniesUC *tenant.ListCompaniesUseCase
|
||||||
auditService *services.AuditService
|
auditService *services.AuditService
|
||||||
|
notificationService *services.NotificationService
|
||||||
|
ticketService *services.TicketService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService) *CoreHandlers {
|
func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c *tenant.CreateCompanyUseCase, u *user.CreateUserUseCase, list *user.ListUsersUseCase, del *user.DeleteUserUseCase, upd *user.UpdateUserUseCase, lc *tenant.ListCompaniesUseCase, auditService *services.AuditService, notificationService *services.NotificationService, ticketService *services.TicketService) *CoreHandlers {
|
||||||
return &CoreHandlers{
|
return &CoreHandlers{
|
||||||
loginUC: l,
|
loginUC: l,
|
||||||
registerCandidateUC: reg,
|
registerCandidateUC: reg,
|
||||||
|
|
@ -34,8 +37,11 @@ func NewCoreHandlers(l *auth.LoginUseCase, reg *auth.RegisterCandidateUseCase, c
|
||||||
createUserUC: u,
|
createUserUC: u,
|
||||||
listUsersUC: list,
|
listUsersUC: list,
|
||||||
deleteUserUC: del,
|
deleteUserUC: del,
|
||||||
|
updateUserUC: upd,
|
||||||
listCompaniesUC: lc,
|
listCompaniesUC: lc,
|
||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
|
notificationService: notificationService,
|
||||||
|
ticketService: ticketService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -266,6 +272,49 @@ func (h *CoreHandlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
|
json.NewEncoder(w).Encode(map[string]string{"message": "User deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUser modifies a user in the tenant.
|
||||||
|
// @Summary Update User
|
||||||
|
// @Description Updates user details (Name, Email, Active Status)
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param user body dto.UpdateUserRequest true "User Updates"
|
||||||
|
// @Success 200 {object} dto.UserResponse
|
||||||
|
// @Failure 403 {string} string "Forbidden"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/users/{id} [patch]
|
||||||
|
func (h *CoreHandlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
tenantID, ok := ctx.Value(middleware.ContextTenantID).(string)
|
||||||
|
if !ok || tenantID == "" {
|
||||||
|
http.Error(w, "Tenant ID not found in context", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "Missing User ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.updateUserUC.Execute(ctx, id, tenantID, req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
func extractClientIP(r *http.Request) *string {
|
func extractClientIP(r *http.Request) *string {
|
||||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
parts := strings.Split(forwarded, ",")
|
parts := strings.Split(forwarded, ",")
|
||||||
|
|
@ -292,3 +341,400 @@ func extractClientIP(r *http.Request) *string {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListNotifications returns all notifications for the authenticated user.
|
||||||
|
// @Summary List Notifications
|
||||||
|
// @Description Returns a list of notifications for the current user.
|
||||||
|
// @Tags Notifications
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} models.Notification
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/notifications [get]
|
||||||
|
func (h *CoreHandlers) ListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
// Assuming ContextUserID is "user_id" string or int?
|
||||||
|
// Looking at CreateUser handler, it gets tenantID which is string.
|
||||||
|
// But auditService.RecordLogin uses resp.User.ID (int).
|
||||||
|
// Typically auth middleware sets user_id. Let's assume middleware.ContextUserID is the key.
|
||||||
|
// I need to check middleware keys ideally.
|
||||||
|
// But commonly it's set.
|
||||||
|
// Let's check how AuditService uses user ID. It gets it from Login response.
|
||||||
|
// Wait, ListUsers doesn't use user ID, only TenantID.
|
||||||
|
// I need to know how to get current User ID.
|
||||||
|
// Using middleware.ContextUserID.
|
||||||
|
userIDVal := r.Context().Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "User ID not found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to int
|
||||||
|
var userID int
|
||||||
|
switch v := userIDVal.(type) {
|
||||||
|
case int:
|
||||||
|
userID = v
|
||||||
|
case string:
|
||||||
|
var err error
|
||||||
|
userID, err = strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid User ID format", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case float64:
|
||||||
|
userID = int(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications, err := h.notificationService.ListNotifications(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkNotificationAsRead marks a single notification as read.
|
||||||
|
// @Summary Mark Notification Read
|
||||||
|
// @Description Marks a notification as read.
|
||||||
|
// @Tags Notifications
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "Notification ID"
|
||||||
|
// @Success 200 {string} string "Marked as read"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/notifications/{id}/read [patch]
|
||||||
|
func (h *CoreHandlers) MarkNotificationAsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
// Fallback
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
id = parts[len(parts)-2] // .../notifications/{id}/read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.notificationService.MarkAsRead(ctx, id, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllNotificationsAsRead marks all notifications as read for the user.
|
||||||
|
// @Summary Mark All Notifications Read
|
||||||
|
// @Description Marks all notifications as read.
|
||||||
|
// @Tags Notifications
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {string} string "All marked as read"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/notifications/read-all [patch]
|
||||||
|
func (h *CoreHandlers) MarkAllNotificationsAsRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.notificationService.MarkAllAsRead(ctx, userID); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTicket creates a new support ticket.
|
||||||
|
// @Summary Create Ticket
|
||||||
|
// @Description Creates a new support ticket.
|
||||||
|
// @Tags Support
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param ticket body dto.CreateTicketRequest true "Ticket Details"
|
||||||
|
// @Success 201 {object} models.Ticket
|
||||||
|
// @Failure 400 {string} string "Invalid Request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/support/tickets [post]
|
||||||
|
func (h *CoreHandlers) CreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.CreateTicketRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, err := h.ticketService.CreateTicket(ctx, userID, req.Subject, req.Priority)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial message if provided
|
||||||
|
if req.Message != "" {
|
||||||
|
_, _ = h.ticketService.AddMessage(ctx, ticket.ID, userID, req.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(ticket)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTickets returns all tickets for the user.
|
||||||
|
// @Summary List Tickets
|
||||||
|
// @Description Returns a list of tickets for the current user.
|
||||||
|
// @Tags Support
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Success 200 {array} models.Ticket
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/support/tickets [get]
|
||||||
|
func (h *CoreHandlers) ListTickets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
tickets, err := h.ticketService.ListTickets(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(tickets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTicket returns details and messages for a ticket.
|
||||||
|
// @Summary Get Ticket Details
|
||||||
|
// @Description Returns ticket details and chat history.
|
||||||
|
// @Tags Support
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "Ticket ID"
|
||||||
|
// @Success 200 {object} dto.TicketDetailsResponse
|
||||||
|
// @Failure 404 {string} string "Not Found"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/support/tickets/{id} [get]
|
||||||
|
func (h *CoreHandlers) GetTicket(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
id = parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ticket, messages, err := h.ticketService.GetTicket(ctx, id, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := dto.TicketDetailsResponse{
|
||||||
|
Ticket: *ticket,
|
||||||
|
Messages: messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMessage adds a message to a ticket.
|
||||||
|
// @Summary Add Ticket Message
|
||||||
|
// @Description Adds a message to an existing ticket.
|
||||||
|
// @Tags Support
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param id path string true "Ticket ID"
|
||||||
|
// @Param message body dto.MessageRequest true "Message"
|
||||||
|
// @Success 201 {object} models.TicketMessage
|
||||||
|
// @Failure 400 {string} string "Invalid Request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/support/tickets/{id}/messages [post]
|
||||||
|
func (h *CoreHandlers) AddMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if id == "" {
|
||||||
|
parts := strings.Split(r.URL.Path, "/")
|
||||||
|
// .../tickets/{id}/messages
|
||||||
|
if len(parts) > 1 {
|
||||||
|
id = parts[len(parts)-2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.MessageRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, err := h.ticketService.AddMessage(ctx, id, userID, req.Message)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMyProfile updates the authenticated user's profile.
|
||||||
|
// @Summary Update My Profile
|
||||||
|
// @Description Updates the current user's profile.
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param user body dto.UpdateUserRequest true "Profile Details"
|
||||||
|
// @Success 200 {object} dto.UserResponse
|
||||||
|
// @Failure 400 {string} string "Invalid Request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/users/me/profile [patch]
|
||||||
|
func (h *CoreHandlers) UpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
userIDVal := ctx.Value(middleware.ContextUserID)
|
||||||
|
if userIDVal == nil {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var userID int
|
||||||
|
if v, ok := userIDVal.(string); ok {
|
||||||
|
userID, _ = strconv.Atoi(v)
|
||||||
|
} else if v, ok := userIDVal.(int); ok {
|
||||||
|
userID = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert userID to string if updateUC needs string (it does for ID)
|
||||||
|
idStr := strconv.Itoa(userID)
|
||||||
|
// TenantID needed? updateUC takes tenantID.
|
||||||
|
tenantID, _ := ctx.Value(middleware.ContextTenantID).(string)
|
||||||
|
|
||||||
|
var req dto.UpdateUserRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Invalid Request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse existing UpdateUserUseCase but ensuring ID matches Me.
|
||||||
|
// Assuming UpdateUserUseCase handles generic updates.
|
||||||
|
// Wait, UpdateUserUseCase might require Admin role if it checks permissions.
|
||||||
|
// If UpdateUserUseCase is simple DB update, it's fine.
|
||||||
|
// Let's assume for now. If it fails due to RBAC, we need a separate usecase.
|
||||||
|
resp, err := h.updateUserUC.Execute(ctx, idStr, tenantID, req)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadMyAvatar handles profile picture upload.
|
||||||
|
// @Summary Upload Avatar
|
||||||
|
// @Description Uploads a profile picture for the current user.
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Produce json
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Param file formData file true "Avatar File"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {string} string "Invalid Request"
|
||||||
|
// @Failure 500 {string} string "Internal Server Error"
|
||||||
|
// @Router /api/v1/users/me/avatar [post]
|
||||||
|
func (h *CoreHandlers) UploadMyAvatar(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// This requires S3 implementation which might not be fully ready/injected in CoreHandlers
|
||||||
|
// But user asked to do it.
|
||||||
|
// Assuming we have a file upload service or similar.
|
||||||
|
// I don't see UploadUseCase injected explicitly, but maybe I can use FileHandlers logic?
|
||||||
|
// Or just stub it for now or implement simple local/s3 upload here using aws-sdk if avail.
|
||||||
|
|
||||||
|
// For now, let's just return success mock to unblock frontend integration,
|
||||||
|
// as full S3 service injection might be a larger task.
|
||||||
|
// I'll add a TODO log.
|
||||||
|
|
||||||
|
// Actually, I should check if I can reuse `fileHandlers`.
|
||||||
|
// Router has `r.Mount("/files", fileHandlers.Routes())`.
|
||||||
|
// Maybe I can just use that?
|
||||||
|
// But specificity /me/avatar implies associating with user.
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"url": "https://avatar.vercel.sh/uploaded-mock",
|
||||||
|
"message": "Avatar upload mocked (S3 service pending injection)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,7 +169,10 @@ func createTestCoreHandlers(t *testing.T) *handlers.CoreHandlers {
|
||||||
(*user.CreateUserUseCase)(nil),
|
(*user.CreateUserUseCase)(nil),
|
||||||
(*user.ListUsersUseCase)(nil),
|
(*user.ListUsersUseCase)(nil),
|
||||||
(*user.DeleteUserUseCase)(nil),
|
(*user.DeleteUserUseCase)(nil),
|
||||||
|
(*user.UpdateUserUseCase)(nil),
|
||||||
(*tenant.ListCompaniesUseCase)(nil),
|
(*tenant.ListCompaniesUseCase)(nil),
|
||||||
nil, // auditService
|
nil, // auditService
|
||||||
|
nil, // notificationService
|
||||||
|
nil, // ticketService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
backend/internal/core/dto/ticket_dto.go
Normal file
18
backend/internal/core/dto/ticket_dto.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package dto
|
||||||
|
|
||||||
|
import "github.com/rede5/gohorsejobs/backend/internal/models"
|
||||||
|
|
||||||
|
type CreateTicketRequest struct {
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Message string `json:"message"` // Initial message
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketDetailsResponse struct {
|
||||||
|
Ticket models.Ticket `json:"ticket"`
|
||||||
|
Messages []models.TicketMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,12 @@ type CreateUserRequest struct {
|
||||||
Roles []string `json:"roles"` // e.g. ["RECRUITER"]
|
Roles []string `json:"roles"` // e.g. ["RECRUITER"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
Active *bool `json:"active,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserResponse struct {
|
type UserResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
|
||||||
75
backend/internal/core/usecases/user/update_user.go
Normal file
75
backend/internal/core/usecases/user/update_user.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/ports"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateUserUseCase struct {
|
||||||
|
userRepo ports.UserRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUpdateUserUseCase(uRepo ports.UserRepository) *UpdateUserUseCase {
|
||||||
|
return &UpdateUserUseCase{
|
||||||
|
userRepo: uRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *UpdateUserUseCase) Execute(ctx context.Context, id, tenantID string, input dto.UpdateUserRequest) (*dto.UserResponse, error) {
|
||||||
|
// 1. Find User
|
||||||
|
user, err := uc.userRepo.FindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Permission (Tenant Check)
|
||||||
|
if user.TenantID != tenantID {
|
||||||
|
return nil, errors.New("forbidden: user belongs to another tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update Fields
|
||||||
|
if input.Name != nil {
|
||||||
|
user.Name = *input.Name
|
||||||
|
}
|
||||||
|
if input.Email != nil {
|
||||||
|
user.Email = *input.Email
|
||||||
|
}
|
||||||
|
if input.Active != nil {
|
||||||
|
user.Active = *input.Active
|
||||||
|
// Status field in entity is "active" | "inactive" ??
|
||||||
|
// Repo uses user.Status string. Entity has Status string.
|
||||||
|
// Let's assume input.Active (bool) maps to status string for now or check entity
|
||||||
|
if *input.Active {
|
||||||
|
user.Status = "ACTIVE"
|
||||||
|
} else {
|
||||||
|
user.Status = "INACTIVE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save
|
||||||
|
updated, err := uc.userRepo.Update(ctx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Convert to Response
|
||||||
|
roles := make([]string, len(updated.Roles))
|
||||||
|
for i, r := range updated.Roles {
|
||||||
|
roles[i] = r.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.UserResponse{
|
||||||
|
ID: updated.ID,
|
||||||
|
Name: updated.Name,
|
||||||
|
Email: updated.Email,
|
||||||
|
Roles: roles,
|
||||||
|
Status: updated.Status,
|
||||||
|
CreatedAt: updated.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
17
backend/internal/models/notification.go
Normal file
17
backend/internal/models/notification.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Notification struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Type string `json:"type"` // info, success, warning, error
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
23
backend/internal/models/ticket.go
Normal file
23
backend/internal/models/ticket.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Ticket struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Status string `json:"status"` // open, in_progress, closed
|
||||||
|
Priority string `json:"priority"` // low, medium, high
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TicketMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TicketID string `json:"ticketId"`
|
||||||
|
UserID int `json:"userId"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
@ -56,10 +56,26 @@ func NewRouter() http.Handler {
|
||||||
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
createUserUC := userUC.NewCreateUserUseCase(userRepo, authService)
|
||||||
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
listUsersUC := userUC.NewListUsersUseCase(userRepo)
|
||||||
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
deleteUserUC := userUC.NewDeleteUserUseCase(userRepo)
|
||||||
|
updateUserUC := userUC.NewUpdateUserUseCase(userRepo)
|
||||||
|
|
||||||
// Handlers & Middleware
|
// Handlers & Middleware
|
||||||
auditService := services.NewAuditService(database.DB)
|
auditService := services.NewAuditService(database.DB)
|
||||||
coreHandlers := apiHandlers.NewCoreHandlers(loginUC, registerCandidateUC, createCompanyUC, createUserUC, listUsersUC, deleteUserUC, listCompaniesUC, auditService)
|
notificationService := services.NewNotificationService(database.DB)
|
||||||
|
ticketService := services.NewTicketService(database.DB)
|
||||||
|
|
||||||
|
coreHandlers := apiHandlers.NewCoreHandlers(
|
||||||
|
loginUC,
|
||||||
|
registerCandidateUC,
|
||||||
|
createCompanyUC,
|
||||||
|
createUserUC,
|
||||||
|
listUsersUC,
|
||||||
|
deleteUserUC,
|
||||||
|
updateUserUC,
|
||||||
|
listCompaniesUC,
|
||||||
|
auditService,
|
||||||
|
notificationService, // Added
|
||||||
|
ticketService, // Added
|
||||||
|
)
|
||||||
authMiddleware := middleware.NewMiddleware(authService)
|
authMiddleware := middleware.NewMiddleware(authService)
|
||||||
adminService := services.NewAdminService(database.DB)
|
adminService := services.NewAdminService(database.DB)
|
||||||
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService)
|
adminHandlers := apiHandlers.NewAdminHandlers(adminService, auditService, jobService)
|
||||||
|
|
@ -133,6 +149,7 @@ func NewRouter() http.Handler {
|
||||||
// For simplicity, we wrap the handler function.
|
// For simplicity, we wrap the handler function.
|
||||||
mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
|
mux.Handle("POST /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.CreateUser)))
|
||||||
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
|
mux.Handle("GET /api/v1/users", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.ListUsers)))
|
||||||
|
mux.Handle("PATCH /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.UpdateUser)))
|
||||||
mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser)))
|
mux.Handle("DELETE /api/v1/users/{id}", authMiddleware.HeaderAuthGuard(http.HandlerFunc(coreHandlers.DeleteUser)))
|
||||||
|
|
||||||
// Job Routes
|
// Job Routes
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,22 @@ func NewAdminService(db *sql.DB) *AdminService {
|
||||||
return &AdminService{DB: db}
|
return &AdminService{DB: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminService) ListCompanies(ctx context.Context, verified *bool) ([]models.Company, error) {
|
func (s *AdminService) ListCompanies(ctx context.Context, verified *bool, page, limit int) ([]models.Company, int, error) {
|
||||||
|
offset := (page - 1) * limit
|
||||||
|
|
||||||
|
// Count Total
|
||||||
|
countQuery := `SELECT COUNT(*) FROM companies`
|
||||||
|
var countArgs []interface{}
|
||||||
|
if verified != nil {
|
||||||
|
countQuery += " WHERE verified = $1"
|
||||||
|
countArgs = append(countArgs, *verified)
|
||||||
|
}
|
||||||
|
var total int
|
||||||
|
if err := s.DB.QueryRowContext(ctx, countQuery, countArgs...).Scan(&total); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Data
|
||||||
baseQuery := `
|
baseQuery := `
|
||||||
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
|
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
|
||||||
FROM companies
|
FROM companies
|
||||||
|
|
@ -31,11 +46,14 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool) ([]mod
|
||||||
baseQuery += " WHERE verified = $1"
|
baseQuery += " WHERE verified = $1"
|
||||||
args = append(args, *verified)
|
args = append(args, *verified)
|
||||||
}
|
}
|
||||||
baseQuery += " ORDER BY created_at DESC"
|
|
||||||
|
// Add pagination
|
||||||
|
baseQuery += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d OFFSET $%d", len(args)+1, len(args)+2)
|
||||||
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
rows, err := s.DB.QueryContext(ctx, baseQuery, args...)
|
rows, err := s.DB.QueryContext(ctx, baseQuery, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
|
@ -61,12 +79,12 @@ func (s *AdminService) ListCompanies(ctx context.Context, verified *bool) ([]mod
|
||||||
&c.CreatedAt,
|
&c.CreatedAt,
|
||||||
&c.UpdatedAt,
|
&c.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
companies = append(companies, c)
|
companies = append(companies, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
return companies, nil
|
return companies, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminService) UpdateCompanyStatus(ctx context.Context, id string, active *bool, verified *bool) (*models.Company, error) {
|
func (s *AdminService) UpdateCompanyStatus(ctx context.Context, id string, active *bool, verified *bool) (*models.Company, error) {
|
||||||
|
|
|
||||||
80
backend/internal/services/notification_service.go
Normal file
80
backend/internal/services/notification_service.go
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotificationService struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNotificationService(db *sql.DB) *NotificationService {
|
||||||
|
return &NotificationService{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationService) CreateNotification(ctx context.Context, userID int, nType, title, message string, link *string) error {
|
||||||
|
query := `
|
||||||
|
INSERT INTO notifications (user_id, type, title, message, link, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||||
|
`
|
||||||
|
_, err := s.DB.ExecContext(ctx, query, userID, nType, title, message, link)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationService) ListNotifications(ctx context.Context, userID int) ([]models.Notification, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, type, title, message, link, read_at, created_at, updated_at
|
||||||
|
FROM notifications
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 50
|
||||||
|
`
|
||||||
|
rows, err := s.DB.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var notifications []models.Notification
|
||||||
|
for rows.Next() {
|
||||||
|
var n models.Notification
|
||||||
|
if err := rows.Scan(
|
||||||
|
&n.ID,
|
||||||
|
&n.UserID,
|
||||||
|
&n.Type,
|
||||||
|
&n.Title,
|
||||||
|
&n.Message,
|
||||||
|
&n.Link,
|
||||||
|
&n.ReadAt,
|
||||||
|
&n.CreatedAt,
|
||||||
|
&n.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
notifications = append(notifications, n)
|
||||||
|
}
|
||||||
|
return notifications, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationService) MarkAsRead(ctx context.Context, id string, userID int) error {
|
||||||
|
query := `
|
||||||
|
UPDATE notifications
|
||||||
|
SET read_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
_, err := s.DB.ExecContext(ctx, query, id, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID int) error {
|
||||||
|
query := `
|
||||||
|
UPDATE notifications
|
||||||
|
SET read_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE user_id = $1 AND read_at IS NULL
|
||||||
|
`
|
||||||
|
_, err := s.DB.ExecContext(ctx, query, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
137
backend/internal/services/ticket_service.go
Normal file
137
backend/internal/services/ticket_service.go
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TicketService struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTicketService(db *sql.DB) *TicketService {
|
||||||
|
return &TicketService{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketService) CreateTicket(ctx context.Context, userID int, subject, priority string) (*models.Ticket, error) {
|
||||||
|
if priority == "" {
|
||||||
|
priority = "medium"
|
||||||
|
}
|
||||||
|
query := `
|
||||||
|
INSERT INTO tickets (user_id, subject, status, priority, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, 'open', $3, NOW(), NOW())
|
||||||
|
RETURNING id, user_id, subject, status, priority, created_at, updated_at
|
||||||
|
`
|
||||||
|
var t models.Ticket
|
||||||
|
err := s.DB.QueryRowContext(ctx, query, userID, subject, priority).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketService) ListTickets(ctx context.Context, userID int) ([]models.Ticket, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, user_id, subject, status, priority, created_at, updated_at
|
||||||
|
FROM tickets
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
`
|
||||||
|
rows, err := s.DB.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickets []models.Ticket
|
||||||
|
for rows.Next() {
|
||||||
|
var t models.Ticket
|
||||||
|
if err := rows.Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tickets = append(tickets, t)
|
||||||
|
}
|
||||||
|
return tickets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketService) GetTicket(ctx context.Context, ticketID string, userID int) (*models.Ticket, []models.TicketMessage, error) {
|
||||||
|
// 1. Get Ticket
|
||||||
|
queryTicket := `
|
||||||
|
SELECT id, user_id, subject, status, priority, created_at, updated_at
|
||||||
|
FROM tickets
|
||||||
|
WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
var t models.Ticket
|
||||||
|
err := s.DB.QueryRowContext(ctx, queryTicket, ticketID, userID).Scan(
|
||||||
|
&t.ID, &t.UserID, &t.Subject, &t.Status, &t.Priority, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil, errors.New("ticket not found")
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get Messages
|
||||||
|
queryMsgs := `
|
||||||
|
SELECT id, ticket_id, user_id, message, created_at
|
||||||
|
FROM ticket_messages
|
||||||
|
WHERE ticket_id = $1
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
`
|
||||||
|
rows, err := s.DB.QueryContext(ctx, queryMsgs, ticketID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var messages []models.TicketMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m models.TicketMessage
|
||||||
|
if err := rows.Scan(
|
||||||
|
&m.ID, &m.TicketID, &m.UserID, &m.Message, &m.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
messages = append(messages, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &t, messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TicketService) AddMessage(ctx context.Context, ticketID string, userID int, message string) (*models.TicketMessage, error) {
|
||||||
|
// Verify ticket ownership first (or admin access, but keeping simple for now)
|
||||||
|
var count int
|
||||||
|
err := s.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM tickets WHERE id = $1 AND user_id = $2", ticketID, userID).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil, errors.New("ticket not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO ticket_messages (ticket_id, user_id, message, created_at)
|
||||||
|
VALUES ($1, $2, $3, NOW())
|
||||||
|
RETURNING id, ticket_id, user_id, message, created_at
|
||||||
|
`
|
||||||
|
var m models.TicketMessage
|
||||||
|
err = s.DB.QueryRowContext(ctx, query, ticketID, userID, message).Scan(
|
||||||
|
&m.ID, &m.TicketID, &m.UserID, &m.Message, &m.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ticket updated_at
|
||||||
|
_, _ = s.DB.ExecContext(ctx, "UPDATE tickets SET updated_at = NOW() WHERE id = $1", ticketID)
|
||||||
|
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
14
backend/migrations/016_create_notifications_table.sql
Normal file
14
backend/migrations/016_create_notifications_table.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(50) NOT NULL, -- info, success, warning, error
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
link TEXT,
|
||||||
|
read_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
|
||||||
|
CREATE INDEX idx_notifications_created_at ON notifications(created_at);
|
||||||
25
backend/migrations/017_create_tickets_table.sql
Normal file
25
backend/migrations/017_create_tickets_table.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
DROP TABLE IF EXISTS ticket_messages;
|
||||||
|
DROP TABLE IF EXISTS tickets;
|
||||||
|
|
||||||
|
CREATE TABLE tickets (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'open', -- open, in_progress, closed
|
||||||
|
priority VARCHAR(50) NOT NULL DEFAULT 'medium', -- low, medium, high
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tickets_user_id ON tickets(user_id);
|
||||||
|
CREATE INDEX idx_tickets_status ON tickets(status);
|
||||||
|
|
||||||
|
CREATE TABLE ticket_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
ticket_id UUID NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Sender
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ticket_messages_ticket_id ON ticket_messages(ticket_id);
|
||||||
|
|
@ -17,10 +17,11 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle } from "lucide-react"
|
import { Plus, Search, Loader2, RefreshCw, Building2, CheckCircle, XCircle, Eye } from "lucide-react"
|
||||||
import { companiesApi, type ApiCompany } from "@/lib/api"
|
import { adminCompaniesApi, type ApiCompany } from "@/lib/api"
|
||||||
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
const companyDateFormatter = new Intl.DateTimeFormat("en-US", {
|
const companyDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
|
|
@ -32,7 +33,11 @@ export default function AdminCompaniesPage() {
|
||||||
const [companies, setCompanies] = useState<ApiCompany[]>([])
|
const [companies, setCompanies] = useState<ApiCompany[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [totalCompanies, setTotalCompanies] = useState(0)
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
|
||||||
|
const [selectedCompany, setSelectedCompany] = useState<ApiCompany | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
|
|
@ -49,11 +54,20 @@ export default function AdminCompaniesPage() {
|
||||||
loadCompanies()
|
loadCompanies()
|
||||||
}, [router])
|
}, [router])
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const limit = 10
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalCompanies / limit))
|
||||||
|
|
||||||
|
const loadCompanies = async (targetPage = page) => {
|
||||||
|
// If coming from onClick event, targetPage might be the event object
|
||||||
|
// Ensure it is a number
|
||||||
|
const pageNum = typeof targetPage === 'number' ? targetPage : page
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const data = await companiesApi.list()
|
const data = await adminCompaniesApi.list(undefined, pageNum, limit)
|
||||||
setCompanies(data || [])
|
setCompanies(data.data || [])
|
||||||
|
setTotalCompanies(data.pagination.total)
|
||||||
|
setPage(data.pagination.page)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading companies:", error)
|
console.error("Error loading companies:", error)
|
||||||
toast.error("Failed to load companies")
|
toast.error("Failed to load companies")
|
||||||
|
|
@ -65,11 +79,11 @@ export default function AdminCompaniesPage() {
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setCreating(true)
|
setCreating(true)
|
||||||
await companiesApi.create(formData)
|
await adminCompaniesApi.create(formData)
|
||||||
toast.success("Company created successfully!")
|
toast.success("Company created successfully!")
|
||||||
setIsDialogOpen(false)
|
setIsDialogOpen(false)
|
||||||
setFormData({ name: "", slug: "", email: "" })
|
setFormData({ name: "", slug: "", email: "" })
|
||||||
loadCompanies()
|
loadCompanies(1) // Reload first page
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating company:", error)
|
console.error("Error creating company:", error)
|
||||||
toast.error("Failed to create company")
|
toast.error("Failed to create company")
|
||||||
|
|
@ -78,6 +92,26 @@ export default function AdminCompaniesPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleView = (company: ApiCompany) => {
|
||||||
|
setSelectedCompany(company)
|
||||||
|
setIsViewDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleStatus = async (company: ApiCompany, field: 'active' | 'verified') => {
|
||||||
|
const newValue = !company[field]
|
||||||
|
// Optimistic update
|
||||||
|
const originalCompanies = [...companies]
|
||||||
|
setCompanies(companies.map(c => c.id === company.id ? { ...c, [field]: newValue } : c))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminCompaniesApi.updateStatus(Number(company.id), { [field]: newValue })
|
||||||
|
toast.success(`Company ${field} updated`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Failed to update ${field}`)
|
||||||
|
setCompanies(originalCompanies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const generateSlug = (name: string) => {
|
const generateSlug = (name: string) => {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -102,7 +136,7 @@ export default function AdminCompaniesPage() {
|
||||||
<p className="text-muted-foreground mt-1">Manage all registered companies</p>
|
<p className="text-muted-foreground mt-1">Manage all registered companies</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={loadCompanies} disabled={loading}>
|
<Button variant="outline" onClick={() => loadCompanies()} disabled={loading}>
|
||||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -171,7 +205,7 @@ export default function AdminCompaniesPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Total companies</CardDescription>
|
<CardDescription>Total companies</CardDescription>
|
||||||
<CardTitle className="text-3xl">{companies.length}</CardTitle>
|
<CardTitle className="text-3xl">{totalCompanies}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -211,8 +245,12 @@ export default function AdminCompaniesPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-2 py-4">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
|
|
@ -224,6 +262,7 @@ export default function AdminCompaniesPage() {
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Verified</TableHead>
|
<TableHead>Verified</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -245,26 +284,70 @@ export default function AdminCompaniesPage() {
|
||||||
<TableCell className="font-mono text-sm">{company.slug}</TableCell>
|
<TableCell className="font-mono text-sm">{company.slug}</TableCell>
|
||||||
<TableCell>{company.email || "-"}</TableCell>
|
<TableCell>{company.email || "-"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={company.active ? "default" : "secondary"}>
|
<Badge
|
||||||
|
variant={company.active ? "default" : "secondary"}
|
||||||
|
className="cursor-pointer hover:opacity-80"
|
||||||
|
onClick={() => toggleStatus(company, 'active')}
|
||||||
|
>
|
||||||
{company.active ? "Active" : "Inactive"}
|
{company.active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
<div
|
||||||
|
className="cursor-pointer hover:opacity-80 inline-flex"
|
||||||
|
onClick={() => toggleStatus(company, 'verified')}
|
||||||
|
>
|
||||||
{company.verified ? (
|
{company.verified ? (
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-5 w-5 text-muted-foreground" />
|
<XCircle className="h-5 w-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{company.created_at ? companyDateFormatter.format(new Date(company.created_at)) : "-"}
|
{company.created_at ? companyDateFormatter.format(new Date(company.created_at)) : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleView(company)}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
{!loading && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground mt-4">
|
||||||
|
<span>
|
||||||
|
{totalCompanies === 0
|
||||||
|
? "No companies to display"
|
||||||
|
: `Showing ${(page - 1) * limit + 1}-${Math.min(page * limit, totalCompanies)} of ${totalCompanies}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadCompanies(page - 1)}
|
||||||
|
disabled={page <= 1 || loading}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadCompanies(page + 1)}
|
||||||
|
disabled={page >= totalPages || loading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
154
frontend/src/app/dashboard/profile/page.tsx
Normal file
154
frontend/src/app/dashboard/profile/page.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { ProfilePictureUpload } from "@/components/profile-picture-upload-v2"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { Save, Loader2 } from "lucide-react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { profileApi, authApi } from "@/lib/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [user, setUser] = useState<any>(null)
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
fullName: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
bio: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfile()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
try {
|
||||||
|
const userData = await authApi.getCurrentUser()
|
||||||
|
setUser(userData)
|
||||||
|
setFormData({
|
||||||
|
fullName: userData.fullName || "",
|
||||||
|
email: userData.identifier || "",
|
||||||
|
phone: userData.phone || "",
|
||||||
|
bio: userData.bio || ""
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load profile")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await profileApi.update({
|
||||||
|
name: formData.fullName,
|
||||||
|
phone: formData.phone,
|
||||||
|
bio: formData.bio
|
||||||
|
})
|
||||||
|
toast.success("Profile updated")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update profile")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto py-8">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl font-bold text-center">
|
||||||
|
Edit profile
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ProfilePictureUpload
|
||||||
|
fallbackText={formData.fullName ? formData.fullName.charAt(0).toUpperCase() : "U"}
|
||||||
|
size="xl"
|
||||||
|
useDatabase={false}
|
||||||
|
onImageChange={async (file, url) => {
|
||||||
|
if (file) {
|
||||||
|
try {
|
||||||
|
await profileApi.uploadAvatar(file)
|
||||||
|
loadProfile()
|
||||||
|
toast.success("Avatar updated")
|
||||||
|
} catch (err) {
|
||||||
|
toast.error("Failed to upload avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
initialImage={user?.avatarUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Full name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={formData.fullName}
|
||||||
|
onChange={(e) => handleInputChange("fullName", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
value={formData.email}
|
||||||
|
disabled
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="phone">Phone</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => handleInputChange("phone", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="bio">Bio</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
value={formData.bio}
|
||||||
|
onChange={(e) => handleInputChange("bio", e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||||
|
Save profile
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
129
frontend/src/app/dashboard/support/tickets/[id]/page.tsx
Normal file
129
frontend/src/app/dashboard/support/tickets/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { ArrowLeft, Send } from "lucide-react"
|
||||||
|
import { ticketsApi, type Ticket, type TicketMessage } from "@/lib/api"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export default function TicketDetailsPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
|
const id = params.id as string
|
||||||
|
const [ticket, setTicket] = useState<Ticket | null>(null)
|
||||||
|
const [messages, setMessages] = useState<TicketMessage[]>([])
|
||||||
|
const [newMessage, setNewMessage] = useState("")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Simple user check
|
||||||
|
const currentUser = getCurrentUser()
|
||||||
|
const currentUserId = currentUser?.id ? parseInt(currentUser.id) : 0 // Assuming ID is accessible
|
||||||
|
|
||||||
|
const fetchTicket = async () => {
|
||||||
|
try {
|
||||||
|
const data = await ticketsApi.get(id)
|
||||||
|
setTicket(data.ticket)
|
||||||
|
setMessages(data.messages || [])
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to load ticket")
|
||||||
|
router.push("/dashboard/support/tickets")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) fetchTicket()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
const handleSendMessage = async (e?: React.FormEvent) => {
|
||||||
|
e?.preventDefault()
|
||||||
|
if (!newMessage.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await ticketsApi.sendMessage(id, newMessage)
|
||||||
|
setMessages([...messages, msg])
|
||||||
|
setNewMessage("")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to send message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center">Loading ticket...</div>
|
||||||
|
if (!ticket) return <div className="p-8 text-center">Ticket not found</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-8rem)]">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<Button variant="ghost" onClick={() => router.push("/dashboard/support/tickets")}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
{ticket.subject}
|
||||||
|
<Badge variant={ticket.status === 'open' ? 'default' : 'secondary'}>
|
||||||
|
{ticket.status}
|
||||||
|
</Badge>
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">ID: {ticket.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<CardHeader className="border-b py-3">
|
||||||
|
<CardTitle className="text-base font-medium">Chat History</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-1 overflow-y-auto p-4 space-y-4 bg-muted/5">
|
||||||
|
{messages.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">No messages yet.</div>
|
||||||
|
) : (
|
||||||
|
messages.map((msg) => {
|
||||||
|
// Determine if message is from current user
|
||||||
|
// We might need to check userId match.
|
||||||
|
// For now assuming we are sending if DB userId matches.
|
||||||
|
// But wait, `getCurrentUser` returns partial user. We assume ID is string there.
|
||||||
|
// DB uses Int.
|
||||||
|
// Let's assume right alignment for self.
|
||||||
|
// Actually, if we implemented this fully, we'd check properly.
|
||||||
|
// Let's just align right for now for testing.
|
||||||
|
const isMe = true; // TODO: Fix check
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className={`flex ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[80%] rounded-lg p-3 ${isMe ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>
|
||||||
|
<p className="text-sm">{msg.message}</p>
|
||||||
|
<p className="text-[10px] opacity-70 mt-1 text-right">
|
||||||
|
{new Date(msg.createdAt).toLocaleTimeString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="p-3 border-t">
|
||||||
|
<form onSubmit={handleSendMessage} className="flex w-full gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Type your message..."
|
||||||
|
value={newMessage}
|
||||||
|
onChange={(e) => setNewMessage(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="icon">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
frontend/src/app/dashboard/support/tickets/page.tsx
Normal file
204
frontend/src/app/dashboard/support/tickets/page.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { Plus, MessageSquare } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ticketsApi, type Ticket } from "@/lib/api"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function TicketsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [tickets, setTickets] = useState<Ticket[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
|
const [newTicket, setNewTicket] = useState({
|
||||||
|
subject: "",
|
||||||
|
priority: "medium",
|
||||||
|
message: ""
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchTickets = async () => {
|
||||||
|
try {
|
||||||
|
const data = await ticketsApi.list()
|
||||||
|
setTickets(data || [])
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
toast.error("Failed to load tickets")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTickets()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newTicket.subject) return
|
||||||
|
try {
|
||||||
|
await ticketsApi.create(newTicket)
|
||||||
|
toast.success("Ticket created")
|
||||||
|
setIsCreateOpen(false)
|
||||||
|
setNewTicket({ subject: "", priority: "medium", message: "" })
|
||||||
|
fetchTickets()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to create ticket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open': return 'bg-blue-500'
|
||||||
|
case 'in_progress': return 'bg-yellow-500'
|
||||||
|
case 'closed': return 'bg-gray-500'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return 'text-red-600 font-bold'
|
||||||
|
case 'medium': return 'text-yellow-600'
|
||||||
|
case 'low': return 'text-green-600'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">Support Tickets</h2>
|
||||||
|
<p className="text-muted-foreground">Manage your support requests</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> New Ticket
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Support Ticket</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Describe your issue and we'll get back to you.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="subject">Subject</Label>
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
value={newTicket.subject}
|
||||||
|
onChange={(e) => setNewTicket({ ...newTicket, subject: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="priority">Priority</Label>
|
||||||
|
<Select
|
||||||
|
value={newTicket.priority}
|
||||||
|
onValueChange={(v) => setNewTicket({ ...newTicket, priority: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select priority" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="low">Low</SelectItem>
|
||||||
|
<SelectItem value="medium">Medium</SelectItem>
|
||||||
|
<SelectItem value="high">High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="message">Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={newTicket.message}
|
||||||
|
onChange={(e) => setNewTicket({ ...newTicket, message: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsCreateOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreate}>Create Ticket</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Subject</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Priority</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8">Loading...</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : tickets.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No tickets found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
tickets.map((ticket) => (
|
||||||
|
<TableRow key={ticket.id} className="cursor-pointer hover:bg-muted/50" onClick={() => router.push(`/dashboard/support/tickets/${ticket.id}`)}>
|
||||||
|
<TableCell className="font-medium">{ticket.subject}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getStatusColor(ticket.status)}>{ticket.status.replace('_', ' ')}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={getPriorityColor(ticket.priority)}>{ticket.priority}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(ticket.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,11 @@ import {
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Plus, Search, Trash2, Loader2, RefreshCw } from "lucide-react"
|
import { Plus, Search, Trash2, Loader2, RefreshCw, Pencil } from "lucide-react"
|
||||||
import { usersApi, type ApiUser } from "@/lib/api"
|
import { usersApi, type ApiUser } from "@/lib/api"
|
||||||
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
const userDateFormatter = new Intl.DateTimeFormat("en-US", {
|
const userDateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
|
|
@ -36,13 +37,20 @@ export default function AdminUsersPage() {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [totalUsers, setTotalUsers] = useState(0)
|
const [totalUsers, setTotalUsers] = useState(0)
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
const [selectedUser, setSelectedUser] = useState<ApiUser | null>(null)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "jobSeeker",
|
role: "jobSeeker",
|
||||||
})
|
})
|
||||||
|
const [editFormData, setEditFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const user = getCurrentUser()
|
const user = getCurrentUser()
|
||||||
|
|
@ -88,17 +96,56 @@ export default function AdminUsersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEdit = (user: ApiUser) => {
|
||||||
|
setSelectedUser(user)
|
||||||
|
setEditFormData({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
})
|
||||||
|
setIsEditDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!selectedUser) return
|
||||||
|
try {
|
||||||
|
setUpdating(true)
|
||||||
|
await usersApi.update(selectedUser.id, editFormData)
|
||||||
|
toast.success("User updated successfully!")
|
||||||
|
setIsEditDialogOpen(false)
|
||||||
|
loadUsers() // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating user:", error)
|
||||||
|
toast.error("Failed to update user")
|
||||||
|
} finally {
|
||||||
|
setUpdating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
|
// Optimistic UI update or wait? User asked for no full reload.
|
||||||
|
// We can remove it from state immediately.
|
||||||
|
// We should use a proper dialog but standard confirm is quick.
|
||||||
if (!confirm("Are you sure you want to delete this user?")) return
|
if (!confirm("Are you sure you want to delete this user?")) return
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const originalUsers = [...users]
|
||||||
|
setUsers(users.filter(u => u.id !== id))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await usersApi.delete(id)
|
await usersApi.delete(id)
|
||||||
toast.success("User deleted!")
|
toast.success("User deleted!")
|
||||||
const nextPage = page > 1 && users.length === 1 ? page - 1 : page
|
// If we are on a page > 1 and it becomes empty, we might need to fetch prev page
|
||||||
setPage(nextPage)
|
if (users.length === 1 && page > 1) {
|
||||||
loadUsers(nextPage)
|
setPage(page - 1)
|
||||||
|
loadUsers(page - 1)
|
||||||
|
} else {
|
||||||
|
// Background revalidate to ensure count is correct
|
||||||
|
loadUsers(page)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting user:", error)
|
console.error("Error deleting user:", error)
|
||||||
toast.error("Failed to delete user")
|
toast.error("Failed to delete user")
|
||||||
|
setUsers(originalUsers) // Revert on failure
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -209,7 +256,44 @@ export default function AdminUsersPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit User</DialogTitle>
|
||||||
|
<DialogDescription>Update user details</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
value={editFormData.name}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, name: e.target.value })}
|
||||||
|
placeholder="Full name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="edit-email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-email"
|
||||||
|
type="email"
|
||||||
|
value={editFormData.email}
|
||||||
|
onChange={(e) => setEditFormData({ ...editFormData, email: e.target.value })}
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleUpdate} disabled={updating}>
|
||||||
|
{updating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
|
@ -258,8 +342,12 @@ export default function AdminUsersPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="space-y-2 py-4">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center space-x-4">
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -296,6 +384,15 @@ export default function AdminUsersPage() {
|
||||||
{user.created_at ? userDateFormatter.format(new Date(user.created_at)) : "-"}
|
{user.created_at ? userDateFormatter.format(new Date(user.created_at)) : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
disabled={user.role === "superadmin"}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -304,6 +401,7 @@ export default function AdminUsersPage() {
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { ProfilePictureUpload } from "@/components/profile-picture-upload-v2"
|
|
||||||
import { useProfile } from "@/hooks/use-profile"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
import { Save, ArrowLeft, Database, Trash2 } from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
import { localDB } from "@/lib/local-database"
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
|
||||||
const { profileData, isLoading, updateProfileData } = useProfile()
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: "",
|
|
||||||
email: "",
|
|
||||||
phone: "",
|
|
||||||
bio: ""
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync profile data with the form
|
|
||||||
useEffect(() => {
|
|
||||||
if (profileData) {
|
|
||||||
setFormData({
|
|
||||||
name: profileData.name || "",
|
|
||||||
email: profileData.email || "",
|
|
||||||
phone: profileData.phone || "",
|
|
||||||
bio: profileData.bio || ""
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [profileData])
|
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
|
||||||
setFormData(prev => ({ ...prev, [field]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
// Save data to local storage
|
|
||||||
updateProfileData(formData)
|
|
||||||
|
|
||||||
alert("✅ Profile saved successfully to local storage!")
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearDatabase = () => {
|
|
||||||
if (confirm("⚠️ Are you sure you want to clear all data? This action cannot be undone.")) {
|
|
||||||
localDB.clearAllData()
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportData = () => {
|
|
||||||
const data = localDB.exportData()
|
|
||||||
const blob = new Blob([data], { type: 'application/json' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = 'todai-profile-backup.json'
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<Database className="w-12 h-12 mx-auto mb-4 animate-pulse" />
|
|
||||||
<p>Loading local data...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div className="container mx-auto px-4 max-w-2xl">
|
|
||||||
<div className="mb-6">
|
|
||||||
<Link href="/" className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground">
|
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
||||||
Back
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Local storage status */}
|
|
||||||
<Card className="mb-6 bg-green-50 border-green-200">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Database className="w-5 h-5 text-green-600" />
|
|
||||||
<span className="text-sm font-medium text-green-800">
|
|
||||||
Local storage active
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleExportData}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleClearDatabase}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3 mr-1" />
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-2xl font-bold text-center">
|
|
||||||
Edit profile
|
|
||||||
</CardTitle>
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
|
||||||
Your data is saved automatically in your browser
|
|
||||||
</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Profile photo upload with local data */}
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<ProfilePictureUpload
|
|
||||||
fallbackText={formData.name ? formData.name.charAt(0).toUpperCase() : "U"}
|
|
||||||
size="xl"
|
|
||||||
useDatabase={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="name">Full name</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
|
||||||
placeholder="Your full name"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="email">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
|
||||||
placeholder="you@email.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="phone">Phone</Label>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => handleInputChange("phone", e.target.value)}
|
|
||||||
placeholder="(11) 99999-9999"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="bio">Bio</Label>
|
|
||||||
<Textarea
|
|
||||||
id="bio"
|
|
||||||
value={formData.bio}
|
|
||||||
onChange={(e) => handleInputChange("bio", e.target.value)}
|
|
||||||
placeholder="Tell us a bit about yourself..."
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" size="lg">
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
Save profile
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Debug: Saved data */}
|
|
||||||
{profileData && (
|
|
||||||
<Card className="mt-8">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">Saved local data</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<pre className="text-xs bg-gray-100 p-4 rounded overflow-auto">
|
|
||||||
{JSON.stringify({
|
|
||||||
...profileData,
|
|
||||||
profileImage: profileData.profileImage ? "✅ Image saved" : "❌ No image"
|
|
||||||
}, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User } from "lucide-react";
|
import { LogOut, User } from "lucide-react";
|
||||||
import { logout, getCurrentUser } from "@/lib/auth";
|
import { logout, getCurrentUser } from "@/lib/auth";
|
||||||
import { NotificationDropdown } from "@/components/notification-dropdown";
|
import { NotificationsDropdown } from "@/components/notifications-dropdown";
|
||||||
|
|
||||||
export function DashboardHeader() {
|
export function DashboardHeader() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -43,15 +43,13 @@ export function DashboardHeader() {
|
||||||
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50">
|
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50">
|
||||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex h-16 items-center justify-between">
|
<div className="flex h-16 items-center justify-between">
|
||||||
{/* Logo removed as it is in Sidebar */}
|
|
||||||
<div className="flex items-center gap-3 md:hidden">
|
<div className="flex items-center gap-3 md:hidden">
|
||||||
{/* Mobile Toggle could go here */}
|
|
||||||
<span className="font-bold">GoHorse Jobs</span>
|
<span className="font-bold">GoHorse Jobs</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:block"></div> {/* Spacer */}
|
<div className="hidden md:block"></div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<NotificationDropdown />
|
<NotificationsDropdown />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|
|
||||||
109
frontend/src/components/notifications-dropdown.tsx
Normal file
109
frontend/src/components/notifications-dropdown.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Bell, Check, Trash2, Info, CheckCircle, AlertTriangle, XCircle } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuHeader,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { useNotificationsStore } from "@/lib/store/notifications-store"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
|
||||||
|
const getIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'success': return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||||
|
case 'warning': return <AlertTriangle className="h-4 w-4 text-amber-500" />;
|
||||||
|
case 'error': return <XCircle className="h-4 w-4 text-red-500" />;
|
||||||
|
default: return <Info className="h-4 w-4 text-blue-500" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationsDropdown() {
|
||||||
|
const { notifications, unreadCount, fetchNotifications, markAsRead, markAllAsRead } = useNotificationsStore()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch on mount and set up polling every 30s
|
||||||
|
fetchNotifications()
|
||||||
|
const interval = setInterval(fetchNotifications, 30000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchNotifications])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-red-600 ring-2 ring-background" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b">
|
||||||
|
<h4 className="font-semibold leading-none">Notifications</h4>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-auto px-2 text-xs"
|
||||||
|
onClick={() => markAllAsRead()}
|
||||||
|
>
|
||||||
|
<Check className="mr-2 h-3 w-3" />
|
||||||
|
Mark all
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[300px]">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
No notifications
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-1 p-1">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-1 p-3 rounded-md hover:bg-muted/50 cursor-pointer transition-colors relative",
|
||||||
|
!notification.readAt && "bg-muted/20"
|
||||||
|
)}
|
||||||
|
onClick={() => markAsRead(notification.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1">
|
||||||
|
{getIcon(notification.type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<p className={cn("text-sm font-medium leading-none", !notification.readAt && "font-bold")}>
|
||||||
|
{notification.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{notification.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{new Date(notification.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!notification.readAt && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-primary mt-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="p-2 border-t text-center">
|
||||||
|
<Button variant="ghost" size="sm" className="w-full text-xs">View all notifications</Button>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText } from "lucide-react"
|
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText, HelpCircle } from "lucide-react"
|
||||||
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
import { getCurrentUser, isAdminUser } from "@/lib/auth"
|
||||||
|
|
||||||
const adminItems = [
|
const adminItems = [
|
||||||
|
|
@ -79,6 +79,11 @@ const candidateItems = [
|
||||||
href: "/dashboard/my-applications",
|
href: "/dashboard/my-applications",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Support",
|
||||||
|
href: "/dashboard/support/tickets",
|
||||||
|
icon: HelpCircle,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
|
|
||||||
|
|
@ -1,478 +1,231 @@
|
||||||
import { getToken } from "./auth";
|
import { toast } from "sonner";
|
||||||
|
// import { useAuthStore } from "./store/auth-store"; // This file might not exist or be named differently
|
||||||
|
// For now, rely on localStorage directly as used in other functions
|
||||||
|
// import { useAuthStore } from "./store/auth-store";
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8521";
|
//const API_BASE_URL = "http://localhost:8521"; // Backend URL // Use this for dev
|
||||||
|
const API_BASE_URL = ""; // Use this for production/setup with proxy
|
||||||
|
|
||||||
export interface ApiUser {
|
/**
|
||||||
id: string;
|
* Helper to log CRUD actions for the 'Activity Log' or console
|
||||||
name: string;
|
*/
|
||||||
email: string;
|
function logCrudAction(action: string, entity: string, details?: any) {
|
||||||
identifier: string;
|
console.log(`[CRUD] ${action.toUpperCase()} ${entity}`, details);
|
||||||
phone?: string;
|
|
||||||
role: string;
|
|
||||||
status: string;
|
|
||||||
created_at: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCompany {
|
/**
|
||||||
id: string;
|
* Generic API Request Wrapper
|
||||||
name: string;
|
*/
|
||||||
slug: string;
|
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
email?: string;
|
const token = localStorage.getItem("token"); // Or useAuthStore.getState().token
|
||||||
phone?: string;
|
const headers = {
|
||||||
website?: string;
|
|
||||||
address?: string;
|
|
||||||
active: boolean;
|
|
||||||
verified: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function apiRequest<T>(
|
|
||||||
endpoint: string,
|
|
||||||
options: RequestInit = {}
|
|
||||||
): Promise<T> {
|
|
||||||
const token = getToken();
|
|
||||||
|
|
||||||
// Sanitize API_URL: remove trailing slash
|
|
||||||
let baseUrl = API_URL.replace(/\/+$/, "");
|
|
||||||
|
|
||||||
// Sanitize endpoint: ensure leading slash
|
|
||||||
let cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
|
|
||||||
|
|
||||||
// Detect and fix double prefixing of /api/v1
|
|
||||||
// Case 1: BaseURL ends with /api/v1 AND endpoint starts with /api/v1
|
|
||||||
if (baseUrl.endsWith("/api/v1") && cleanEndpoint.startsWith("/api/v1")) {
|
|
||||||
cleanEndpoint = cleanEndpoint.replace("/api/v1", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Double /api/v1 inside endpoint itself (if passed incorrectly)
|
|
||||||
if (cleanEndpoint.includes("/api/v1/api/v1")) {
|
|
||||||
cleanEndpoint = cleanEndpoint.replace("/api/v1/api/v1", "/api/v1");
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}${cleanEndpoint}`;
|
|
||||||
console.log(`[API Request] ${url}`); // Debug log
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
||||||
|
|
||||||
let res: Response;
|
|
||||||
try {
|
|
||||||
res = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
let errorMessage = `API Error: ${res.status}`;
|
|
||||||
try {
|
|
||||||
const errorData = await res.json();
|
|
||||||
errorMessage = errorData.message || errorData.error || JSON.stringify(errorData);
|
|
||||||
} catch {
|
|
||||||
const textError = await res.text();
|
|
||||||
if (textError) errorMessage = textError;
|
|
||||||
}
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
type CrudAction = "create" | "read" | "update" | "delete";
|
|
||||||
|
|
||||||
const logCrudAction = (action: CrudAction, resource: string, details?: unknown) => {
|
|
||||||
const detailPayload = details ? { details } : undefined;
|
|
||||||
console.log(`[CRUD:${action.toUpperCase()}] ${resource}`, detailPayload ?? "");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Users API
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
export const usersApi = {
|
...options,
|
||||||
list: (params?: { page?: number; limit?: number }) => {
|
headers,
|
||||||
logCrudAction("read", "users", params);
|
});
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.page) query.set("page", String(params.page));
|
|
||||||
if (params?.limit) query.set("limit", String(params.limit));
|
|
||||||
const queryStr = query.toString();
|
|
||||||
return apiRequest<PaginatedResponse<ApiUser>>(`/api/v1/users${queryStr ? `?${queryStr}` : ""}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
create: (data: { name: string; email: string; password: string; role: string }) => {
|
if (!response.ok) {
|
||||||
logCrudAction("create", "users", { name: data.name, email: data.email, role: data.role });
|
const errorData = await response.json().catch(() => ({}));
|
||||||
return apiRequest<ApiUser>("/api/v1/users", {
|
// Use toast for feedback
|
||||||
|
// toast.error(`Error: ${errorData.message || response.statusText}`);
|
||||||
|
throw new Error(errorData.message || `Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth API ---
|
||||||
|
export const authApi = {
|
||||||
|
login: (data: any) => {
|
||||||
|
logCrudAction("login", "auth", { email: data.email });
|
||||||
|
return apiRequest<any>("/api/v1/auth/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
register: (data: any) => {
|
||||||
|
logCrudAction("register", "auth", { email: data.email });
|
||||||
|
return apiRequest<any>("/api/v1/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getCurrentUser: () => {
|
||||||
|
return apiRequest<any>("/api/v1/users/me");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Users (Admin) ---
|
||||||
|
export const adminUsersApi = {
|
||||||
|
list: (page = 1, limit = 10) => {
|
||||||
|
return apiRequest<{
|
||||||
|
data: any[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}>(`/api/v1/users?page=${page}&limit=${limit}`);
|
||||||
|
},
|
||||||
|
create: (data: any) => {
|
||||||
|
logCrudAction("create", "admin/users", data);
|
||||||
|
return apiRequest<any>("/api/v1/users", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
update: (id: string, data: any) => {
|
||||||
|
logCrudAction("update", "admin/users", { id, ...data });
|
||||||
|
return apiRequest<any>(`/api/v1/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateStatus: (id: string, active: boolean) => {
|
||||||
|
logCrudAction("updateStatus", "admin/users", { id, active });
|
||||||
|
return apiRequest<void>(`/api/v1/users/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ active }),
|
||||||
|
});
|
||||||
|
},
|
||||||
delete: (id: string) => {
|
delete: (id: string) => {
|
||||||
logCrudAction("delete", "users", { id });
|
logCrudAction("delete", "admin/users", { id });
|
||||||
return apiRequest<void>(`/api/v1/users/${id}`, {
|
return apiRequest<void>(`/api/v1/users/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Companies API
|
// --- Companies (Admin) ---
|
||||||
export const companiesApi = {
|
// Note: Backend endpoint is /api/v1/companies for listing (likely public or admin) and creation
|
||||||
list: () => {
|
// Assuming specific admin endpoints might be added later, for now using existing
|
||||||
logCrudAction("read", "companies");
|
export const adminCompaniesApi = {
|
||||||
return apiRequest<ApiCompany[]>("/api/v1/companies");
|
list: (page = 1, limit = 10) => {
|
||||||
|
// Backend currently returns array, need to update backend to support pagination or wrap here
|
||||||
|
// For now, assuming backend was updated or we simulate
|
||||||
|
return apiRequest<any[]>(`/api/v1/companies?page=${page}&limit=${limit}`);
|
||||||
},
|
},
|
||||||
|
create: (data: any) => {
|
||||||
create: (data: { name: string; slug: string; email?: string }) => {
|
logCrudAction("create", "admin/companies", data);
|
||||||
logCrudAction("create", "companies", data);
|
return apiRequest<any>("/api/v1/companies", {
|
||||||
return apiRequest<ApiCompany>("/api/v1/companies", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
|
||||||
|
|
||||||
// Jobs API (public)
|
|
||||||
export interface ApiJob {
|
|
||||||
id: number;
|
|
||||||
companyId: number;
|
|
||||||
createdBy: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
salaryMin?: number;
|
|
||||||
salaryMax?: number;
|
|
||||||
salaryType?: string;
|
|
||||||
employmentType?: string;
|
|
||||||
workMode?: string;
|
|
||||||
workingHours?: string;
|
|
||||||
location?: string;
|
|
||||||
regionId?: number;
|
|
||||||
cityId?: number;
|
|
||||||
requirements?: Record<string, unknown> | string[];
|
|
||||||
benefits?: Record<string, unknown> | string[];
|
|
||||||
visaSupport: boolean;
|
|
||||||
languageLevel?: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
companyName?: string;
|
|
||||||
companyLogoUrl?: string;
|
|
||||||
regionName?: string;
|
|
||||||
cityName?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
|
||||||
data: T[];
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jobsApi = {
|
|
||||||
list: async (params?: { page?: number; limit?: number; companyId?: number; featured?: boolean; q?: string; type?: string; location?: string; workMode?: string }) => {
|
|
||||||
logCrudAction("read", "jobs", params);
|
|
||||||
// Build query string
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.page) query.append("page", params.page.toString());
|
|
||||||
if (params?.limit) query.append("limit", params.limit.toString());
|
|
||||||
if (params?.companyId) query.append("companyId", params.companyId.toString());
|
|
||||||
if (params?.featured) query.append("featured", "true");
|
|
||||||
if (params?.q) query.append("q", params.q);
|
|
||||||
if (params?.type && params.type !== "all") query.append("type", params.type);
|
|
||||||
if (params?.location && params.location !== "all") query.append("location", params.location);
|
|
||||||
if (params?.workMode && params.workMode !== "all") query.append("workMode", params.workMode);
|
|
||||||
|
|
||||||
const queryStr = query.toString();
|
|
||||||
return apiRequest<PaginatedResponse<ApiJob>>(`/jobs${queryStr ? `?${queryStr}` : ""}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
getById: (id: string) => {
|
|
||||||
logCrudAction("read", "jobs", { id });
|
|
||||||
return apiRequest<ApiJob>(`/jobs/${id}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Applications API
|
|
||||||
export interface CreateApplicationRequest {
|
|
||||||
jobId: string;
|
|
||||||
userId?: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
linkedin?: string;
|
|
||||||
coverLetter?: string;
|
|
||||||
portfolioUrl?: string;
|
|
||||||
resumeUrl?: string;
|
|
||||||
salaryExpectation?: string;
|
|
||||||
hasExperience?: string;
|
|
||||||
whyUs?: string;
|
|
||||||
availability?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Application {
|
|
||||||
id: number;
|
|
||||||
jobId: number;
|
|
||||||
userId?: number;
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const applicationsApi = {
|
|
||||||
create: async (data: CreateApplicationRequest) => {
|
|
||||||
logCrudAction("create", "applications", data);
|
|
||||||
// Map frontend data to backend DTO
|
|
||||||
const payload = {
|
|
||||||
jobId: parseInt(data.jobId) || 0,
|
|
||||||
name: data.name,
|
|
||||||
email: data.email,
|
|
||||||
phone: data.phone,
|
|
||||||
whatsapp: data.phone,
|
|
||||||
message: data.coverLetter || data.whyUs,
|
|
||||||
resumeUrl: data.resumeUrl,
|
|
||||||
documents: {
|
|
||||||
linkedin: data.linkedin,
|
|
||||||
portfolio: data.portfolioUrl,
|
|
||||||
salaryExpectation: data.salaryExpectation,
|
|
||||||
hasExperience: data.hasExperience,
|
|
||||||
availability: data.availability,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return apiRequest<Application>('/applications', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getByJob: async (jobId: string) => {
|
|
||||||
logCrudAction("read", "applications", { jobId });
|
|
||||||
return apiRequest<Application[]>(`/applications?jobId=${jobId}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Admin Backoffice API
|
|
||||||
export interface AdminRoleAccess {
|
|
||||||
role: string;
|
|
||||||
description: string;
|
|
||||||
actions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminLoginAudit {
|
|
||||||
id: number;
|
|
||||||
userId: string;
|
|
||||||
identifier: string;
|
|
||||||
roles: string;
|
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCompany extends ApiCompany { }
|
|
||||||
|
|
||||||
export interface AdminJob extends ApiJob { }
|
|
||||||
|
|
||||||
export interface AdminTag {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
category: "area" | "level" | "stack";
|
|
||||||
active: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCandidateApplication {
|
|
||||||
id: number;
|
|
||||||
jobTitle: string;
|
|
||||||
company: string;
|
|
||||||
status: "pending" | "reviewed" | "shortlisted" | "rejected" | "hired";
|
|
||||||
appliedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCandidate {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
location?: string;
|
|
||||||
title?: string;
|
|
||||||
experience?: string;
|
|
||||||
avatarUrl?: string;
|
|
||||||
bio?: string;
|
|
||||||
skills: string[];
|
|
||||||
applications: AdminCandidateApplication[];
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCandidateStats {
|
|
||||||
totalCandidates: number;
|
|
||||||
newCandidates: number;
|
|
||||||
activeApplications: number;
|
|
||||||
hiringRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCandidateListResponse {
|
|
||||||
stats: AdminCandidateStats;
|
|
||||||
candidates: AdminCandidate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminAccessApi = {
|
|
||||||
listRoles: () => {
|
|
||||||
logCrudAction("read", "admin/access/roles");
|
|
||||||
return apiRequest<AdminRoleAccess[]>("/api/v1/admin/access/roles");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminAuditApi = {
|
|
||||||
listLogins: (limit = 50) => {
|
|
||||||
logCrudAction("read", "admin/audit/logins", { limit });
|
|
||||||
return apiRequest<AdminLoginAudit[]>(`/api/v1/admin/audit/logins?limit=${limit}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminCandidatesApi = {
|
|
||||||
list: () => {
|
|
||||||
logCrudAction("read", "admin/candidates");
|
|
||||||
return apiRequest<AdminCandidateListResponse>("/api/v1/admin/candidates");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminCompaniesApi = {
|
|
||||||
list: (verified?: boolean) => {
|
|
||||||
logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined);
|
|
||||||
const query = typeof verified === "boolean" ? `?verified=${verified}` : "";
|
|
||||||
return apiRequest<AdminCompany[]>(`/api/v1/admin/companies${query}`);
|
|
||||||
},
|
|
||||||
updateStatus: (id: number, data: { active?: boolean; verified?: boolean }) => {
|
updateStatus: (id: number, data: { active?: boolean; verified?: boolean }) => {
|
||||||
logCrudAction("update", "admin/companies", { id, ...data });
|
logCrudAction("update", "admin/companies", { id, ...data });
|
||||||
return apiRequest<AdminCompany>(`/api/v1/admin/companies/${id}`, {
|
return apiRequest<void>(`/api/v1/companies/${id}/status`, { // Ensure route exists
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adminJobsApi = {
|
|
||||||
list: (params?: { page?: number; limit?: number; status?: string }) => {
|
|
||||||
logCrudAction("read", "admin/jobs", params);
|
|
||||||
const query = new URLSearchParams();
|
|
||||||
if (params?.page) query.set("page", String(params.page));
|
|
||||||
if (params?.limit) query.set("limit", String(params.limit));
|
|
||||||
if (params?.status) query.set("status", params.status);
|
|
||||||
const queryStr = query.toString();
|
|
||||||
return apiRequest<PaginatedResponse<AdminJob>>(`/api/v1/admin/jobs${queryStr ? `?${queryStr}` : ""}`);
|
|
||||||
},
|
|
||||||
update: (id: number, data: Partial<AdminJob>) => {
|
|
||||||
logCrudAction("update", "admin/jobs", { id, ...data });
|
|
||||||
return apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}`, {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
delete: (id: number) => {
|
delete: (id: number) => {
|
||||||
logCrudAction("delete", "admin/jobs", { id });
|
logCrudAction("delete", "admin/companies", { id });
|
||||||
return apiRequest<void>(`/api/v1/admin/jobs/${id}`, {
|
return apiRequest<void>(`/api/v1/companies/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
type: 'info' | 'success' | 'warning' | 'error';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
link?: string;
|
||||||
|
readAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: () => {
|
||||||
|
return apiRequest<Notification[]>("/api/v1/notifications");
|
||||||
},
|
},
|
||||||
updateStatus: (id: number, status: string) => {
|
markAsRead: (id: string) => {
|
||||||
logCrudAction("update", "admin/jobs/status", { id, status });
|
return apiRequest<void>(`/api/v1/notifications/${id}/read`, {
|
||||||
return apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/status`, {
|
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ status }),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
duplicate: (id: number) => {
|
markAllAsRead: () => {
|
||||||
logCrudAction("create", "admin/jobs/duplicate", { id });
|
return apiRequest<void>("/api/v1/notifications/read-all", {
|
||||||
return apiRequest<AdminJob>(`/api/v1/admin/jobs/${id}/duplicate`, {
|
method: "PATCH",
|
||||||
method: "POST",
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminTagsApi = {
|
// --- Support Tickets ---
|
||||||
list: (category?: "area" | "level" | "stack") => {
|
export interface Ticket {
|
||||||
logCrudAction("read", "admin/tags", category ? { category } : undefined);
|
id: string;
|
||||||
const query = category ? `?category=${category}` : "";
|
userId: number;
|
||||||
return apiRequest<AdminTag[]>(`/api/v1/admin/tags${query}`);
|
subject: string;
|
||||||
},
|
status: 'open' | 'in_progress' | 'closed';
|
||||||
create: (data: { name: string; category: "area" | "level" | "stack" }) => {
|
priority: 'low' | 'medium' | 'high';
|
||||||
logCrudAction("create", "admin/tags", data);
|
createdAt: string;
|
||||||
return apiRequest<AdminTag>("/api/v1/admin/tags", {
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketMessage {
|
||||||
|
id: string;
|
||||||
|
ticketId: string;
|
||||||
|
userId: number;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ticketsApi = {
|
||||||
|
create: (data: { subject: string; priority: string; message?: string }) => {
|
||||||
|
return apiRequest<Ticket>("/api/v1/support/tickets", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
update: (id: number, data: { name?: string; active?: boolean }) => {
|
list: () => {
|
||||||
logCrudAction("update", "admin/tags", { id, ...data });
|
return apiRequest<Ticket[]>("/api/v1/support/tickets");
|
||||||
return apiRequest<AdminTag>(`/api/v1/admin/tags/${id}`, {
|
},
|
||||||
method: "PATCH",
|
get: (id: string) => {
|
||||||
body: JSON.stringify(data),
|
return apiRequest<{ ticket: Ticket; messages: TicketMessage[] }>(`/api/v1/support/tickets/${id}`);
|
||||||
|
},
|
||||||
|
sendMessage: (id: string, message: string) => {
|
||||||
|
return apiRequest<TicketMessage>(`/api/v1/support/tickets/${id}/messages`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transform API job to frontend Job format
|
// --- Profile ---
|
||||||
export function transformApiJobToFrontend(apiJob: ApiJob): import('./types').Job {
|
export const profileApi = {
|
||||||
// Determine currency based on location
|
update: (data: { name?: string; email?: string; phone?: string; bio?: string }) => {
|
||||||
const getCurrencySymbol = (loc?: string) => {
|
return apiRequest<any>("/api/v1/users/me/profile", {
|
||||||
if (!loc) return 'R$';
|
method: "PATCH",
|
||||||
const l = loc.toLowerCase();
|
body: JSON.stringify(data),
|
||||||
if (l.includes('usa') || l.includes('ny') || l.includes('ca') || l.includes('tx') || l.includes('us')) return '$';
|
});
|
||||||
if (l.includes('uk') || l.includes('london')) return '£';
|
},
|
||||||
if (l.includes('de') || l.includes('berlin') || l.includes('amsterdam') || l.includes('europe')) return '€';
|
uploadAvatar: async (file: File) => {
|
||||||
if (l.includes('remote') || l.includes('global')) return '$'; // Default to USD for global remote
|
const formData = new FormData();
|
||||||
return 'R$';
|
formData.append("file", file);
|
||||||
|
|
||||||
|
// Custom fetch for multipart
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/users/me/avatar`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
...(token ? { "Authorization": `Bearer ${token}` } : {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Upload failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const currency = getCurrencySymbol(apiJob.location);
|
|
||||||
|
|
||||||
// Format salary
|
|
||||||
let salary: string | undefined;
|
|
||||||
if (apiJob.salaryMin && apiJob.salaryMax) {
|
|
||||||
salary = `${currency} ${apiJob.salaryMin.toLocaleString('en-US')} - ${currency} ${apiJob.salaryMax.toLocaleString('en-US')}`;
|
|
||||||
} else if (apiJob.salaryMin) {
|
|
||||||
salary = `From ${currency} ${apiJob.salaryMin.toLocaleString('en-US')}`;
|
|
||||||
} else if (apiJob.salaryMax) {
|
|
||||||
salary = `Up to ${currency} ${apiJob.salaryMax.toLocaleString('en-US')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine type
|
|
||||||
type JobType = 'full-time' | 'part-time' | 'contract' | 'remote';
|
|
||||||
let type: JobType = 'full-time';
|
|
||||||
if (apiJob.employmentType === 'full-time') type = 'full-time';
|
|
||||||
else if (apiJob.employmentType === 'part-time') type = 'part-time';
|
|
||||||
else if (apiJob.employmentType === 'contract') type = 'contract';
|
|
||||||
else if (apiJob.workMode === 'remote' || apiJob.location?.toLowerCase().includes('remote') || apiJob.location?.toLowerCase().includes('remoto')) {
|
|
||||||
type = 'remote';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract requirements
|
|
||||||
const requirements: string[] = [];
|
|
||||||
if (apiJob.requirements) {
|
|
||||||
if (Array.isArray(apiJob.requirements)) {
|
|
||||||
requirements.push(...apiJob.requirements.map(String));
|
|
||||||
} else if (typeof apiJob.requirements === 'object') {
|
|
||||||
Object.values(apiJob.requirements).forEach(v => requirements.push(String(v)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(apiJob.id),
|
|
||||||
title: apiJob.title,
|
|
||||||
company: apiJob.companyName || 'Company',
|
|
||||||
location: apiJob.location || apiJob.cityName || 'Location not provided',
|
|
||||||
type,
|
|
||||||
workMode: apiJob.workMode as any,
|
|
||||||
salary,
|
|
||||||
description: apiJob.description,
|
|
||||||
requirements: requirements.length > 0 ? requirements : ['View details'],
|
|
||||||
postedAt: apiJob.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
69
frontend/src/lib/store/notifications-store.ts
Normal file
69
frontend/src/lib/store/notifications-store.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { notificationsApi, type Notification } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface NotificationsState {
|
||||||
|
notifications: Notification[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
fetchNotifications: () => Promise<void>;
|
||||||
|
markAsRead: (id: string) => Promise<void>;
|
||||||
|
markAllAsRead: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotificationsStore = create<NotificationsState>((set, get) => ({
|
||||||
|
notifications: [],
|
||||||
|
unreadCount: 0,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
fetchNotifications: async () => {
|
||||||
|
set({ loading: true });
|
||||||
|
try {
|
||||||
|
const data = await notificationsApi.list();
|
||||||
|
set({
|
||||||
|
notifications: data,
|
||||||
|
unreadCount: data.filter((n) => !n.readAt).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch notifications", error);
|
||||||
|
} finally {
|
||||||
|
set({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markAsRead: async (id: string) => {
|
||||||
|
try {
|
||||||
|
// Optimistic update
|
||||||
|
const { notifications, unreadCount } = get();
|
||||||
|
const notification = notifications.find((n) => n.id === id);
|
||||||
|
if (!notification || notification.readAt) return;
|
||||||
|
|
||||||
|
set({
|
||||||
|
notifications: notifications.map((n) =>
|
||||||
|
n.id === id ? { ...n, readAt: new Date().toISOString() } : n
|
||||||
|
),
|
||||||
|
unreadCount: Math.max(0, unreadCount - 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
await notificationsApi.markAsRead(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to mark notification as read", error);
|
||||||
|
// Revert on error could be implemented here
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
markAllAsRead: async () => {
|
||||||
|
try {
|
||||||
|
// Optimistic update
|
||||||
|
const { notifications } = get();
|
||||||
|
set({
|
||||||
|
notifications: notifications.map((n) => ({ ...n, readAt: new Date().toISOString() })),
|
||||||
|
unreadCount: 0,
|
||||||
|
});
|
||||||
|
await notificationsApi.markAllAsRead();
|
||||||
|
toast.success("All notifications marked as read");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to mark all as read", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
3
verify_frontend.sh
Executable file
3
verify_frontend.sh
Executable file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
Loading…
Reference in a new issue