Loading...
+Merchant ID: ...
+From 15359237b36759e59c28a690d739fe6be9d57a59 Mon Sep 17 00:00:00 2001 From: devzeeh <148837352+devzeeh@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:50:41 +0800 Subject: [PATCH 1/7] feat: implement database schema and administrative merchant management workflows --- backend/cmd/app/main.go | 6 + backend/internal/admin/admin_merchant.go | 237 +++++++++++++++++++ backend/internal/auth/merchant_signup.go | 185 +++++++++++++++ docs/unicardv1.sql | 2 +- frontend/assets/admin/admin_merchant.js | 52 ++-- frontend/assets/admin/merchant_info.js | 205 ++++++++++++++++ frontend/assets/js/merchant_signup.js | 108 +++++++++ frontend/templates/admin/admin_merchant.html | 44 ---- frontend/templates/admin/merchant_info.html | 216 +++++++++++++++++ frontend/templates/auth/merchant_signup.html | 97 ++++++++ 10 files changed, 1074 insertions(+), 78 deletions(-) create mode 100644 backend/internal/auth/merchant_signup.go create mode 100644 frontend/assets/admin/merchant_info.js create mode 100644 frontend/assets/js/merchant_signup.js create mode 100644 frontend/templates/admin/merchant_info.html create mode 100644 frontend/templates/auth/merchant_signup.html diff --git a/backend/cmd/app/main.go b/backend/cmd/app/main.go index a740a94..7b2406d 100644 --- a/backend/cmd/app/main.go +++ b/backend/cmd/app/main.go @@ -73,6 +73,8 @@ func main() { mux.HandleFunc("GET /signup", authHandler.SignupView) mux.HandleFunc("POST /v1/loginauth", authHandler.LoginAuthHandler) // Login authentication endpoint mux.HandleFunc("POST /v1/signupauth", authHandler.SignupHandler) + mux.HandleFunc("GET /merchant-signup", authHandler.MerchantSignupView) + mux.HandleFunc("POST /v1/merchant-signup", authHandler.MerchantSignupHandler) mux.HandleFunc("GET /admin-signup", authHandler.AdminSignupView) mux.HandleFunc("POST /v1/admin-signup", authHandler.AdminSignupHandler) mux.HandleFunc("POST /v1/signup/check-details", authHandler.CheckDetailsHandler) @@ -102,6 +104,10 @@ func main() { mux.HandleFunc("POST /v1/admin/{username}/terminals/add", adminHanlder.AddTerminalHandler) mux.HandleFunc("GET /admin/{username}/settings", adminHanlder.SystemSettingsView) mux.HandleFunc("POST /v1/admin/{username}/merchants/add", adminHanlder.AddMerchantHandler) + mux.HandleFunc("GET /admin/{username}/merchants/{id}", adminHanlder.MerchantInfoView) + mux.HandleFunc("GET /v1/admin/{username}/merchants/{id}/data", adminHanlder.MerchantInfoDataHandler) + mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/approve", adminHanlder.ApproveMerchantHandler) + mux.HandleFunc("POST /v1/admin/{username}/merchants/{id}/reject", adminHanlder.RejectMerchantHandler) mux.HandleFunc("GET /admin/{username}/card-inventory", adminHanlder.CardInventoryView) mux.HandleFunc("GET /v1/admin/{username}/card-inventory-data", adminHanlder.CardInventoryDataHandler) mux.HandleFunc("GET /admin/{username}/addcard", adminHanlder.AddCardsView) diff --git a/backend/internal/admin/admin_merchant.go b/backend/internal/admin/admin_merchant.go index a18ce76..a9791c6 100644 --- a/backend/internal/admin/admin_merchant.go +++ b/backend/internal/admin/admin_merchant.go @@ -1,6 +1,8 @@ package admin import ( + "database/sql" + "encoding/json" "fmt" "log" "net/http" @@ -178,3 +180,238 @@ func (h *Handler) MerchantManagementDataHandler(w http.ResponseWriter, r *http.R }) log.Println("MerchantManagementDataHandler finished") } + +type ApproveMerchantRequest struct { + CommissionRate string `json:"commissionRate" validate:"required"` + SettlementBank string `json:"settlementBank" validate:"required"` + SettlementName string `json:"settlementName" validate:"required"` + SettlementAccount string `json:"settlementAccount" validate:"required"` + TerminalSn string `json:"terminalSn" validate:"required"` + DeviceName string `json:"deviceName"` +} + +func (h *Handler) ApproveMerchantHandler(w http.ResponseWriter, r *http.Request) { + merchantID := r.PathValue("id") + adminUsername := r.PathValue("username") + + if merchantID == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"}) + return + } + + var req ApproveMerchantRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Invalid request payload"}) + return + } + + // Begin TX + tx, err := h.DB.Begin() + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) + return + } + defer tx.Rollback() + + // Get admin user_id + var adminUserID string + err = tx.QueryRow("SELECT user_id FROM users WHERE username = ?", adminUsername).Scan(&adminUserID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Admin user not found"}) + return + } + + // Get merchant user_id + var merchantUserID string + err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) + return + } + + // Update merchants table + _, err = tx.Exec(` + UPDATE merchants + SET status = 'active', + commission_rate = ?, + settlement_bank_name = ?, + settlement_account_name = ?, + settlement_account_number = ?, + approved_by = ?, + approved_at = CURRENT_TIMESTAMP + WHERE merchant_id = ?`, + req.CommissionRate, req.SettlementBank, req.SettlementName, req.SettlementAccount, adminUserID, merchantID) + + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update merchant"}) + return + } + + // Update users table + _, err = tx.Exec("UPDATE users SET status = 'active' WHERE user_id = ?", merchantUserID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to update user status"}) + return + } + + // Update terminals table + // First get business address for location details + var businessAddress string + _ = tx.QueryRow("SELECT business_address FROM merchants WHERE merchant_id = ?", merchantID).Scan(&businessAddress) + + _, err = tx.Exec("UPDATE terminals SET merchant_id = ?, location_details = ?, status = 'active' WHERE terminal_sn = ?", merchantUserID, businessAddress, req.TerminalSn) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to assign terminal"}) + return + } + + if err := tx.Commit(); err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize approval"}) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant approved successfully"}) +} + +func (h *Handler) RejectMerchantHandler(w http.ResponseWriter, r *http.Request) { + merchantID := r.PathValue("id") + if merchantID == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant ID is required"}) + return + } + + tx, err := h.DB.Begin() + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Database error"}) + return + } + defer tx.Rollback() + + var merchantUserID string + err = tx.QueryRow("SELECT user_id FROM merchants WHERE merchant_id = ?", merchantID).Scan(&merchantUserID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{Success: false, Message: "Merchant not found"}) + return + } + + _, err = tx.Exec("UPDATE merchants SET status = 'rejected' WHERE merchant_id = ?", merchantID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to reject merchant"}) + return + } + + _, err = tx.Exec("UPDATE users SET status = 'inactive' WHERE user_id = ?", merchantUserID) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to deactivate user"}) + return + } + + if err := tx.Commit(); err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{Success: false, Message: "Failed to finalize rejection"}) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{Success: true, Message: "Merchant rejected successfully"}) +} + +type MerchantDetailsData struct { + MerchantID string + UserID string + BusinessName string + BusinessType string + RegistrationNum string + BusinessAddress string + OwnerName string + BusinessEmail string + BusinessPhone string + Status string + CommissionRate float64 + SettlementBank string + SettlementName string + SettlementAcct string + CreatedAt string +} + +type MerchantInfoViewData struct { + Page string + Username string +} + +func (h *Handler) MerchantInfoView(w http.ResponseWriter, r *http.Request) { + username := r.PathValue("username") + + data := MerchantInfoViewData{ + Page: "merchants", + Username: username, + } + + err := h.Tpl.ExecuteTemplate(w, "merchant_info.html", data) + if err != nil { + fmt.Printf("Template execution error: %v\n", err) + } +} + +func (h *Handler) MerchantInfoDataHandler(w http.ResponseWriter, r *http.Request) { + merchantID := r.PathValue("id") + + if merchantID == "" { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Merchant ID required", + }) + return + } + + var m MerchantDetailsData + var commRate sql.NullFloat64 + var setBank, setName, setAcct, regNum sql.NullString + + err := h.DB.QueryRow(` + SELECT merchant_id, user_id, business_name, business_type, business_registration_number, + business_address, owner_name, business_email, business_phone, status, + commission_rate, settlement_bank_name, settlement_account_name, + settlement_account_number, created_at + FROM merchants WHERE merchant_id = ?`, merchantID).Scan( + &m.MerchantID, &m.UserID, &m.BusinessName, &m.BusinessType, ®Num, + &m.BusinessAddress, &m.OwnerName, &m.BusinessEmail, &m.BusinessPhone, &m.Status, + &commRate, &setBank, &setName, &setAcct, &m.CreatedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + jsonwrite.WriteJSON(w, http.StatusNotFound, jsonwrite.APIResponse{ + Success: false, + Message: "Merchant not found", + }) + return + } + log.Println("Error querying merchant details:", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Database error", + }) + return + } + + if regNum.Valid { + m.RegistrationNum = regNum.String + } + + if commRate.Valid { + m.CommissionRate = commRate.Float64 + } + if setBank.Valid { + m.SettlementBank = setBank.String + } + if setName.Valid { + m.SettlementName = setName.String + } + if setAcct.Valid { + m.SettlementAcct = setAcct.String + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Data: m, + }) +} diff --git a/backend/internal/auth/merchant_signup.go b/backend/internal/auth/merchant_signup.go new file mode 100644 index 0000000..834ae10 --- /dev/null +++ b/backend/internal/auth/merchant_signup.go @@ -0,0 +1,185 @@ +package authentication + +import ( + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "strings" + "time" + "unicard-go/backend/internal/pkg/account" + jsonwrite "unicard-go/backend/internal/pkg/handler" + + "github.com/go-playground/validator/v10" +) + +type MerchantSignupRequest struct { + BusinessName string `json:"businessName" validate:"required"` + BusinessType string `json:"businessType" validate:"required"` + BusinessAddress string `json:"businessAddress" validate:"required"` + OwnerName string `json:"ownerName" validate:"required"` + BusinessPhone string `json:"businessPhone" validate:"required"` + BusinessEmail string `json:"businessEmail" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` +} + +func (h *Handler) MerchantSignupView(w http.ResponseWriter, r *http.Request) { + log.Printf("Merchant Signup view is running...") + h.Tpl.ExecuteTemplate(w, "merchant_signup.html", nil) +} + +func (h *Handler) MerchantSignupHandler(w http.ResponseWriter, r *http.Request) { + var req MerchantSignupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding merchant signup JSON: %v", err) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to parse JSON request", + }) + return + } + + // Clean inputs + req.BusinessName = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessName))) + req.BusinessAddress = strings.Title(strings.ToLower(strings.TrimSpace(req.BusinessAddress))) + req.OwnerName = strings.Title(strings.ToLower(strings.TrimSpace(req.OwnerName))) + req.BusinessEmail = strings.ToLower(strings.TrimSpace(req.BusinessEmail)) + req.BusinessPhone = strings.TrimSpace(req.BusinessPhone) + req.BusinessType = strings.TrimSpace(req.BusinessType) + req.Password = strings.TrimSpace(req.Password) + + // Validate inputs + err := Validate.Struct(req) + if err != nil { + log.Printf("Validation failed: %v", err) + errorMessage := "Invalid input provided." + var validationErrs validator.ValidationErrors + if errors.As(err, &validationErrs) { + errorMap := map[string]string{ + "BusinessName": "Business name is required.", + "BusinessType": "Business type is required.", + "BusinessAddress": "Business address is required.", + "OwnerName": "Owner name is required.", + "BusinessPhone": "Business phone is required.", + "BusinessEmail": "Please provide a valid business email address.", + "Password": "Password must be at least 6 characters long.", + } + if msg, ok := errorMap[validationErrs[0].Field()]; ok { + errorMessage = msg + } + } + + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: errorMessage, + }) + return + } + + // Check if email already exists + exists, err := account.IsEmailExist(h.DB, req.BusinessEmail) + if err != nil { + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Database error", + }) + return + } + if exists { + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Email already registered", + }) + return + } + + // Hash password + hashedPassword, err := account.HashPassword(req.Password) + if err != nil { + log.Printf("Error hashing password: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "System error processing password.", + }) + return + } + + // Begin transaction + ctx := r.Context() + tx, err := h.DB.BeginTx(ctx, nil) + if err != nil { + log.Printf("Error starting transaction: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "System error starting transaction.", + }) + return + } + defer tx.Rollback() + + // Generate IDs (Format: YYMMminsecxxxxx) + timestamp := time.Now().Format("01020605") // MMDDYYss + + nUser, _ := rand.Int(rand.Reader, big.NewInt(10000)) + userID := fmt.Sprintf("UNI-%s%04d", timestamp, nUser.Int64()) + + nMerchant, _ := rand.Int(rand.Reader, big.NewInt(10000)) + merchantID := fmt.Sprintf("MCH-%s%04d", timestamp, nMerchant.Int64()) + + // Insert User + username := req.BusinessEmail // using email as username + userStmt := `INSERT INTO users (user_id, username, name, email, phone_number, password_hash, role, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + _, err = tx.ExecContext(ctx, userStmt, userID, username, req.OwnerName, req.BusinessEmail, req.BusinessPhone, string(hashedPassword), "merchant_admin", "active") + if err != nil { + log.Printf("Error creating user: %v", err) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to create user account. Email or phone might already exist.", + }) + return + } + + // Generate registration number (UCBZ-MMDDss-xxxxxxxxxx) + nReg, _ := rand.Int(rand.Reader, big.NewInt(10000000000)) + regNum := fmt.Sprintf("UCBZ-%s-%010d", time.Now().Format("010205"), nReg.Int64()) + + // Insert Merchant with placeholder 'PENDING' for settlement fields + fixedCommissionRate := 2.00 + merchStmt := `INSERT INTO merchants ( + merchant_id, business_name, business_type, business_registration_number, business_address, + user_id, owner_name, business_email, business_phone, commission_rate, + settlement_account_name, settlement_account_number, settlement_bank_name, status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err = tx.ExecContext(ctx, merchStmt, + merchantID, req.BusinessName, req.BusinessType, regNum, req.BusinessAddress, + userID, req.OwnerName, req.BusinessEmail, req.BusinessPhone, fixedCommissionRate, + "PENDING", "PENDING", "PENDING", "pending approval", + ) + + if err != nil { + log.Printf("Error creating merchant: %v", err) + jsonwrite.WriteJSON(w, http.StatusBadRequest, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to create merchant profile.", + }) + return + } + + if err := tx.Commit(); err != nil { + log.Printf("Error committing tx: %v", err) + jsonwrite.WriteJSON(w, http.StatusInternalServerError, jsonwrite.APIResponse{ + Success: false, + Message: "Failed to finalize account creation", + }) + return + } + + jsonwrite.WriteJSON(w, http.StatusOK, jsonwrite.APIResponse{ + Success: true, + Message: "Merchant application submitted successfully", + }) +} diff --git a/docs/unicardv1.sql b/docs/unicardv1.sql index 9f70d93..995f5ea 100644 --- a/docs/unicardv1.sql +++ b/docs/unicardv1.sql @@ -50,7 +50,7 @@ CREATE TABLE merchants ( settlement_account_number VARCHAR(50) NOT NULL COMMENT 'The actual bank account number or mobile number (GCash/Maya) for payouts', settlement_bank_name VARCHAR(100) NOT NULL COMMENT 'The target bank or e-wallet company name (e.g., BDO, BPI, GCash, Maya)', - status ENUM('pending_approval', 'active', 'suspended') DEFAULT 'pending_approval' COMMENT 'Operational state of the merchant ecosystem tenancy', + status ENUM('pending_approval', 'approved', 'rejected', 'active', 'suspended') DEFAULT 'pending_approval' COMMENT 'Operational state of the merchant ecosystem tenancy', approved_by VARCHAR(50) NULL COMMENT 'The user_id of the Super Admin who verified and activated this company profile', approved_at TIMESTAMP NULL COMMENT 'The specific date and timestamp when the business was activated', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'Auto-generated date and time record of the initial registration request', diff --git a/frontend/assets/admin/admin_merchant.js b/frontend/assets/admin/admin_merchant.js index 4a7e13f..1d78a2c 100644 --- a/frontend/assets/admin/admin_merchant.js +++ b/frontend/assets/admin/admin_merchant.js @@ -8,7 +8,7 @@ let totalItemsCount = 0; let currentMerchants = []; let unassignedTerminals = []; -window.renderAssignedTerminals = function(terminals) { +window.renderAssignedTerminals = function (terminals) { if (!terminals || terminals.length === 0) { return 'No terminals'; } @@ -79,20 +79,20 @@ function highlightText(text, queryTerms) { div.innerText = text; return div.innerHTML; } - + const escapedTerms = queryTerms.filter(t => t.length > 0).map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); if (escapedTerms.length === 0) { const div = document.createElement('div'); div.innerText = text; return div.innerHTML; } - + const regex = new RegExp(`(${escapedTerms.join('|')})`, 'gi'); - + const div = document.createElement('div'); div.innerText = text; const safeText = div.innerHTML; - + return safeText.replace(regex, '$1'); } @@ -113,7 +113,7 @@ function renderTable() { currentMerchants.forEach(merchant => { const tr = document.createElement('tr'); - + const highlightedBusinessName = highlightText(merchant.business_name, queryTerms); const highlightedMerchantId = highlightText(merchant.merchant_id, queryTerms); const highlightedOwnerName = highlightText(merchant.owner_name, queryTerms); @@ -122,22 +122,8 @@ function renderTable() { tr.className = 'hover:bg-gray-50 cursor-pointer transition duration-150'; tr.onclick = (e) => { if (e.target.closest('button')) return; // Ignore button clicks - document.getElementById('modalBusinessName').textContent = merchant.business_name; - document.getElementById('modalMerchantId').textContent = merchant.merchant_id; - document.getElementById('modalBusinessType').textContent = merchant.business_type.replace(/_/g, ' '); - document.getElementById('modalOwnerName').textContent = merchant.owner_name; - document.getElementById('modalContactEmail').textContent = merchant.business_email; - document.getElementById('modalContactPhone').textContent = merchant.business_phone; - - const statusEl = document.getElementById('modalStatus'); - statusEl.textContent = merchant.status.replace(/_/g, ' '); - statusEl.className = 'capitalize px-2 py-1 text-xs font-medium rounded-full'; - if (merchant.status.toLowerCase() === 'active') statusEl.classList.add('bg-green-100', 'text-green-800'); - else if (merchant.status.toLowerCase() === 'pending_approval') statusEl.classList.add('bg-yellow-100', 'text-yellow-800'); - else statusEl.classList.add('bg-red-100', 'text-red-800'); - - document.getElementById('modalCreatedAt').textContent = new Date(merchant.created_at).toLocaleDateString(); - document.getElementById('merchantDetailsModal').classList.remove('hidden'); + const adminUsername = window.location.pathname.split('/')[2]; + window.location.href = `/admin/${adminUsername}/merchants/${merchant.merchant_id}`; }; tr.innerHTML = ` @@ -209,10 +195,10 @@ function renderPagination() { document.addEventListener('DOMContentLoaded', () => { fetchMerchants(); fetchUnassignedTerminals(); - + // Delegate change event for terminal selection const container = document.getElementById('merchantBlocksContainer'); - container.addEventListener('change', function(e) { + container.addEventListener('change', function (e) { if (e.target.classList.contains('terminal-sn-select')) { const selectedOption = e.target.options[e.target.selectedIndex]; const block = e.target.closest('.merchant-block'); @@ -226,7 +212,7 @@ document.addEventListener('DOMContentLoaded', () => { }); // Auto-format fields on focus out to trim spaces and format as Title Case - container.addEventListener('focusout', function(e) { + container.addEventListener('focusout', function (e) { if (e.target.tagName === 'INPUT') { let val = e.target.value; if (e.target.type === 'text' && !['businessPhone', 'commissionRate', 'registrationNum', 'deviceName'].includes(e.target.name)) { @@ -283,7 +269,7 @@ document.addEventListener('DOMContentLoaded', () => { if (sortOrder) sortOrder.value = 'desc'; if (filterCategory) filterCategory.value = ''; if (filterStatus) filterStatus.value = ''; - + currentSearchQuery = ''; currentSortOrder = 'desc'; currentCategory = ''; @@ -297,7 +283,7 @@ document.getElementById('addAnotherMerchantBtn').addEventListener('click', () => const container = document.getElementById('merchantBlocksContainer'); const firstBlock = container.querySelector('.merchant-block'); const newBlock = firstBlock.cloneNode(true); - + // Clear inputs (keep default commission rate) const inputs = newBlock.querySelectorAll('input, select'); inputs.forEach(input => { @@ -305,17 +291,17 @@ document.getElementById('addAnotherMerchantBtn').addEventListener('click', () => input.value = ''; } }); - + const blockCount = container.querySelectorAll('.merchant-block').length + 1; newBlock.querySelector('.merchant-title').textContent = `Merchant #${blockCount}`; - + const removeBtn = newBlock.querySelector('.remove-merchant-btn'); removeBtn.classList.remove('hidden'); - removeBtn.addEventListener('click', function() { + removeBtn.addEventListener('click', function () { newBlock.remove(); updateMerchantTitles(); }); - + container.appendChild(newBlock); }); @@ -328,10 +314,10 @@ function updateMerchantTitles() { document.getElementById('onboardForm').addEventListener('submit', function (e) { e.preventDefault(); - + const blocks = document.querySelectorAll('.merchant-block'); const merchantsData = []; - + blocks.forEach(block => { const inputs = block.querySelectorAll('input, select'); const merchantObj = {}; diff --git a/frontend/assets/admin/merchant_info.js b/frontend/assets/admin/merchant_info.js new file mode 100644 index 0000000..47d83e6 --- /dev/null +++ b/frontend/assets/admin/merchant_info.js @@ -0,0 +1,205 @@ +let unassignedTerminals = []; + +function fetchUnassignedTerminals() { + const adminUsername = window.location.pathname.split('/')[2]; + fetch(`/v1/admin/${adminUsername}/terminals/unassigned`) + .then(res => res.json()) + .then(result => { + if (result.success && result.data) { + unassignedTerminals = result.data; + document.querySelectorAll('.terminal-sn-select').forEach(populateTerminalDropdown); + } + }) + .catch(error => console.error("Error fetching unassigned terminals", error)); +} + +function populateTerminalDropdown(selectElement) { + selectElement.innerHTML = ''; + unassignedTerminals.forEach(t => { + const opt = document.createElement('option'); + opt.value = t.terminal_sn; + opt.textContent = t.terminal_sn; + opt.dataset.deviceName = t.device_name; + selectElement.appendChild(opt); + }); +} + +function fetchMerchantData() { + const pathParts = window.location.pathname.split('/'); + const adminUsername = pathParts[2]; + const merchantId = pathParts[4]; + + fetch(`/v1/admin/${adminUsername}/merchants/${merchantId}/data`) + .then(res => res.json()) + .then(result => { + if (result.success && result.data) { + populateMerchantUI(result.data); + } else { + console.error("Failed to load merchant data:", result.message); + alert("Failed to load merchant data: " + (result.message || "Unknown error")); + } + }) + .catch(error => { + console.error("Error fetching merchant data:", error); + alert("Network error while loading merchant data."); + }); +} + +function populateMerchantUI(merchant) { + document.getElementById('businessName').textContent = merchant.BusinessName; + document.getElementById('merchantId').textContent = merchant.MerchantID; + document.getElementById('businessType').textContent = merchant.BusinessType.replace(/_/g, ' '); + document.getElementById('registrationNum').textContent = merchant.RegistrationNum || 'N/A'; + document.getElementById('businessAddress').textContent = merchant.BusinessAddress; + document.getElementById('createdAt').textContent = new Date(merchant.CreatedAt).toLocaleDateString(); + + document.getElementById('ownerName').textContent = merchant.OwnerName; + document.getElementById('businessEmail').textContent = merchant.BusinessEmail; + document.getElementById('businessPhone').textContent = merchant.BusinessPhone; + + const statusEl = document.getElementById('merchantStatus'); + const statusLower = merchant.Status.toLowerCase(); + statusEl.textContent = merchant.Status; + statusEl.className = 'capitalize px-4 py-2 text-sm font-semibold rounded-full'; + + if (statusLower === 'active') { + statusEl.classList.add('bg-green-100', 'text-green-800'); + } else if (statusLower === 'pending_approval' || statusLower === 'pending approval') { + statusEl.classList.add('bg-yellow-100', 'text-yellow-800'); + } else { + statusEl.classList.add('bg-red-100', 'text-red-800'); + } + + if (statusLower === 'active') { + document.getElementById('settlementDetailsContainer').classList.remove('hidden'); + document.getElementById('commissionRate').textContent = merchant.CommissionRate + '%'; + document.getElementById('settlementBank').textContent = merchant.SettlementBank || 'N/A'; + document.getElementById('settlementName').textContent = merchant.SettlementName || 'N/A'; + document.getElementById('settlementAcct').textContent = merchant.SettlementAcct || 'N/A'; + } + + if (statusLower === 'pending_approval' || statusLower === 'pending approval') { + const actionButtons = document.getElementById('actionButtons'); + actionButtons.classList.remove('hidden'); + actionButtons.dataset.merchantId = merchant.MerchantID; + actionButtons.dataset.businessName = merchant.BusinessName; + } +} + +document.addEventListener('DOMContentLoaded', () => { + fetchUnassignedTerminals(); + fetchMerchantData(); + + const btnApprove = document.getElementById('btnApproveMerchant'); + const btnReject = document.getElementById('btnRejectMerchant'); + const actionButtons = document.getElementById('actionButtons'); + const approveModal = document.getElementById('approveMerchantModal'); + + if (btnApprove && actionButtons) { + btnApprove.addEventListener('click', () => { + const businessName = actionButtons.dataset.businessName; + const merchantId = actionButtons.dataset.merchantId; + document.getElementById('approveModalBusinessName').textContent = businessName; + document.getElementById('approveMerchantId').value = merchantId; + + const terminalSelect = document.getElementById('approveTerminalSn'); + populateTerminalDropdown(terminalSelect); + approveModal.classList.remove('hidden'); + }); + } + + if (btnReject && actionButtons) { + btnReject.addEventListener('click', () => { + const merchantId = actionButtons.dataset.merchantId; + if (!merchantId) return; + + if (!confirm("Are you sure you want to reject this merchant application?")) return; + + const adminUsername = window.location.pathname.split('/')[2]; + fetch(`/v1/admin/${adminUsername}/merchants/${merchantId}/reject`, { + method: 'POST' + }) + .then(res => res.json()) + .then(result => { + if (result.success) { + alert("Merchant application rejected successfully."); + window.location.reload(); + } else { + alert(result.message || "Failed to reject merchant."); + } + }) + .catch(err => { + console.error("Error rejecting merchant:", err); + alert("Network error. Please try again."); + }); + }); + } + + // Terminal selection auto-fill for approve form + const approveTerminalSn = document.getElementById('approveTerminalSn'); + const approveDeviceName = document.getElementById('approveDeviceName'); + if (approveTerminalSn && approveDeviceName) { + approveTerminalSn.addEventListener('change', (e) => { + const selectedOption = e.target.options[e.target.selectedIndex]; + if (selectedOption && selectedOption.dataset.deviceName) { + approveDeviceName.value = selectedOption.dataset.deviceName; + } else { + approveDeviceName.value = ''; + } + }); + } + + const approveForm = document.getElementById('approveForm'); + if (approveForm) { + approveForm.addEventListener('submit', (e) => { + e.preventDefault(); + + const merchantId = document.getElementById('approveMerchantId').value; + const commissionRate = document.getElementById('approveCommissionRate').value; + const settlementBank = document.getElementById('approveSettlementBank').value; + const settlementName = document.getElementById('approveSettlementName').value; + const settlementAccount = document.getElementById('approveSettlementAccount').value; + const terminalSn = document.getElementById('approveTerminalSn').value; + const deviceName = document.getElementById('approveDeviceName').value; + + const payload = { + commissionRate, + settlementBank, + settlementName, + settlementAccount, + terminalSn, + deviceName + }; + + const alertBox = document.getElementById('approveFormAlert'); + alertBox.classList.add('hidden'); + + const adminUsername = window.location.pathname.split('/')[2]; + fetch(`/v1/admin/${adminUsername}/merchants/${merchantId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(res => res.json()) + .then(result => { + alertBox.classList.remove('hidden', 'bg-red-50', 'text-red-600', 'bg-green-50', 'text-green-600'); + if (result.success) { + alertBox.classList.add('bg-green-50', 'text-green-600'); + alertBox.textContent = result.message || "Merchant approved successfully!"; + setTimeout(() => { + window.location.reload(); + }, 1500); + } else { + alertBox.classList.add('bg-red-50', 'text-red-600'); + alertBox.textContent = result.message || "An error occurred."; + } + }) + .catch(err => { + console.error("Error approving merchant:", err); + alertBox.classList.remove('hidden', 'bg-green-50', 'text-green-600'); + alertBox.classList.add('bg-red-50', 'text-red-600'); + alertBox.textContent = "Network error. Please try again."; + }); + }); + } +}); diff --git a/frontend/assets/js/merchant_signup.js b/frontend/assets/js/merchant_signup.js new file mode 100644 index 0000000..62be364 --- /dev/null +++ b/frontend/assets/js/merchant_signup.js @@ -0,0 +1,108 @@ +document.addEventListener("DOMContentLoaded", () => { + const signupForm = document.getElementById("merchantSignupForm"); + const formAlert = document.getElementById("formAlert"); + const submitBtn = document.getElementById("submitBtn"); + + if (!signupForm) return; + + signupForm.addEventListener("submit", async (e) => { + e.preventDefault(); + hideAlert(); + + const businessName = document.getElementById("businessName").value.trim(); + const businessType = document.getElementById("businessType").value; + const businessAddress = document.getElementById("businessAddress").value.trim(); + const ownerName = document.getElementById("ownerName").value.trim(); + const businessPhone = document.getElementById("businessPhone").value.trim(); + const businessEmail = document.getElementById("businessEmail").value.trim(); + const password = document.getElementById("password").value; + const confirmPassword = document.getElementById("confirmPassword").value; + + // Basic validation + if (password !== confirmPassword) { + showAlert("Passwords do not match.", "error"); + return; + } + + if (password.length < 6) { + showAlert("Password must be at least 6 characters long.", "error"); + return; + } + + // Prepare payload + const payload = { + businessName, + businessType, + businessAddress, + ownerName, + businessPhone, + businessEmail, + password + }; + + // Disable button to prevent double submission + setLoading(true); + + try { + const response = await fetch("/v1/merchant-signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const data = await response.json(); + + if (response.ok) { + showAlert("Application submitted successfully! Please wait for admin approval before logging in.", "success"); + signupForm.reset(); + // Optionally redirect to login page after a delay + setTimeout(() => { + window.location.href = "/login"; + }, 3000); + } else { + showAlert(data.message || "Failed to submit application. Please try again.", "error"); + } + } catch (error) { + console.error("Signup error:", error); + showAlert("A network error occurred. Please try again later.", "error"); + } finally { + setLoading(false); + } + }); + + function showAlert(message, type) { + formAlert.textContent = message; + formAlert.classList.remove("hidden", "bg-red-100", "text-red-700", "border-red-400", "bg-green-100", "text-green-700", "border-green-400", "border"); + + if (type === "error") { + formAlert.classList.add("bg-red-100", "text-red-700", "border-red-400", "border"); + } else { + formAlert.classList.add("bg-green-100", "text-green-700", "border-green-400", "border"); + } + } + + function hideAlert() { + formAlert.classList.add("hidden"); + formAlert.textContent = ""; + } + + function setLoading(isLoading) { + if (isLoading) { + submitBtn.disabled = true; + submitBtn.innerHTML = ` + + Submitting... + `; + submitBtn.classList.add("opacity-75", "cursor-not-allowed"); + } else { + submitBtn.disabled = false; + submitBtn.innerHTML = "Submit Application"; + submitBtn.classList.remove("opacity-75", "cursor-not-allowed"); + } + } +}); diff --git a/frontend/templates/admin/admin_merchant.html b/frontend/templates/admin/admin_merchant.html index 4cd4387..c66affe 100644 --- a/frontend/templates/admin/admin_merchant.html +++ b/frontend/templates/admin/admin_merchant.html @@ -276,50 +276,6 @@
Merchant ID: ...
+Please provide the missing settlement and terminal information to activate this merchant.
+ +diff --git a/frontend/templates/admin/merchant_info.html b/frontend/templates/admin/merchant_info.html new file mode 100644 index 0000000..5783b37 --- /dev/null +++ b/frontend/templates/admin/merchant_info.html @@ -0,0 +1,216 @@ + + +
+ + +
+ + + + + + + +
+