feat: added a side bar
This commit is contained in:
@@ -208,7 +208,7 @@ func AddressHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
PageNumbers: pageNumbers,
|
PageNumbers: pageNumbers,
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "address/address.html", map[string]interface{}{
|
utils.Render(w, "address.html", map[string]interface{}{
|
||||||
"Title": "Addresses",
|
"Title": "Addresses",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowAdminNav": true,
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func AdminDashboardHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
housesLeftPercent = 0 // Set default value on error
|
housesLeftPercent = 0 // Set default value on error
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{
|
utils.Render(w, "dashboard.html", map[string]interface{}{
|
||||||
"Title": "Admin Dashboard",
|
"Title": "Admin Dashboard",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"VolunteerCount": volunteerCount,
|
"VolunteerCount": volunteerCount,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ func ReportsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ShowAdminNav": role == 1,
|
"ShowAdminNav": role == 1,
|
||||||
"ShowVolunteerNav": role != 1,
|
"ShowVolunteerNav": role != 1,
|
||||||
"UserName": username,
|
"UserName": username,
|
||||||
"ActiveSection": "reports",
|
"ActiveSection": "reports",
|
||||||
"Category": category,
|
"Category": category,
|
||||||
"ReportID": reportID,
|
"ReportID": reportID,
|
||||||
"DateFrom": dateFrom,
|
"DateFrom": dateFrom,
|
||||||
@@ -177,20 +177,23 @@ func getAllReportDefinitions() map[string][]ReportDefinition {
|
|||||||
return map[string][]ReportDefinition{
|
return map[string][]ReportDefinition{
|
||||||
"users": {
|
"users": {
|
||||||
{
|
{
|
||||||
ID: "users_by_role",
|
ID: "volunteer_participation_rate", // get all the appointment(done, notdone, total) poll(done, not doen, total)
|
||||||
Name: "Users by Role",
|
Name: "Volunteer participation rate",
|
||||||
Description: "Count of users grouped by their role",
|
Description: "Count of users grouped by their role",
|
||||||
SQL: `SELECT
|
SQL: `SELECT
|
||||||
CASE
|
u.user_id,
|
||||||
WHEN role_id = 1 THEN 'Admin'
|
u.first_name,
|
||||||
WHEN role_id = 2 THEN 'Volunteer'
|
u.last_name,
|
||||||
ELSE 'Unknown'
|
COUNT(p.poll_id) AS total_polls,
|
||||||
END as role,
|
COUNT(a.user_id) AS total_appointments,
|
||||||
COUNT(*) as user_count,
|
case
|
||||||
COUNT(CASE WHEN created_at >= ?1 THEN 1 END) as new_this_period
|
WHEN COUNT(a.user_id) = 0 THEN NULL -- avoid division by zero
|
||||||
FROM users
|
ELSE ROUND(CAST(COUNT(p.poll_id) AS numeric) / COUNT(a.user_id), 2)
|
||||||
GROUP BY role_id
|
END AS poll_to_appointment_rate
|
||||||
ORDER BY role_id`,
|
from users u
|
||||||
|
LEFT JOIN poll p ON u.user_id = p.user_id
|
||||||
|
LEFT JOIN appointment a ON u.user_id = a.user_id
|
||||||
|
GROUP BY u.user_id, u.first_name, u.last_name;`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ID: "volunteer_activity",
|
ID: "volunteer_activity",
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func TeamBuilderHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
unassignedVolunteers = append(unassignedVolunteers, vol)
|
unassignedVolunteers = append(unassignedVolunteers, vol)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "volunteer/team_builder.html", map[string]interface{}{
|
utils.Render(w, "team_builder.html", map[string]interface{}{
|
||||||
"Title": "Team Builder",
|
"Title": "Team Builder",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowAdminNav": true,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func VolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
user = append(user, b)
|
user = append(user, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "volunteer/volunteer.html", map[string]interface{}{
|
utils.Render(w, "volunteer.html", map[string]interface{}{
|
||||||
"Title": "Assigned Volunteers",
|
"Title": "Assigned Volunteers",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowAdminNav": true,
|
||||||
@@ -66,7 +66,7 @@ func EditVolunteerHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "volunteer/edit_volunteer.html", map[string]interface{}{
|
utils.Render(w, "edit_volunteer.html", map[string]interface{}{
|
||||||
"Title": "Edit Volunteer",
|
"Title": "Edit Volunteer",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowAdminNav": true,
|
||||||
|
|||||||
@@ -30,29 +30,12 @@ func getDefaultRedirectURL(role int) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to render error pages with consistent data
|
|
||||||
func renderLoginError(w http.ResponseWriter, errorMsg string) {
|
|
||||||
utils.Render(w, "login.html", map[string]interface{}{
|
|
||||||
"Error": errorMsg,
|
|
||||||
"Title": "Login",
|
|
||||||
"IsAuthenticated": false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderRegisterError(w http.ResponseWriter, errorMsg string) {
|
|
||||||
utils.Render(w, "register.html", map[string]interface{}{
|
|
||||||
"Error": errorMsg,
|
|
||||||
"Title": "Register",
|
|
||||||
"IsAuthenticated": false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create and sign JWT token
|
// Helper function to create and sign JWT token
|
||||||
func createJWTToken(userID, role int) (string, time.Time, error) {
|
func createJWTToken(userID, role int) (string, time.Time, error) {
|
||||||
|
|
||||||
err := godotenv.Load() // or specify path: godotenv.Load("/path/to/.env")
|
err := godotenv.Load() // or specify path: godotenv.Load("/path/to/.env")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error loading .env file: %v", err)
|
log.Fatalf("Error loading .env file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get individual components from environment variables
|
// Get individual components from environment variables
|
||||||
@@ -60,7 +43,6 @@ func createJWTToken(userID, role int) (string, time.Time, error) {
|
|||||||
|
|
||||||
var jwtKey = []byte(jwtSecret)
|
var jwtKey = []byte(jwtSecret)
|
||||||
|
|
||||||
|
|
||||||
expirationTime := time.Now().Add(12 * time.Hour)
|
expirationTime := time.Now().Add(12 * time.Hour)
|
||||||
claims := &models.Claims{
|
claims := &models.Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -113,7 +95,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if email == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
http.Redirect(w, r, "/?error=EmailAndPasswordRequired", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +112,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Login failed for email %s: %v", email, err)
|
log.Printf("Login failed for email %s: %v", email, err)
|
||||||
http.Redirect(w, r, "/?error=InvalidCredentials", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +120,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
|
err = bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Password verification failed for user ID %d", userID)
|
log.Printf("Password verification failed for user ID %d", userID)
|
||||||
http.Redirect(w, r, "/?error=InvalidCredentials", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +128,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
tokenString, expirationTime, err := createJWTToken(userID, role)
|
tokenString, expirationTime, err := createJWTToken(userID, role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("JWT token creation failed for user ID %d: %v", userID, err)
|
log.Printf("JWT token creation failed for user ID %d: %v", userID, err)
|
||||||
http.Redirect(w, r, "/?error=InternalError", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +141,6 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
utils.Render(w, "layout.html", map[string]interface{}{
|
utils.Render(w, "layout.html", map[string]interface{}{
|
||||||
@@ -179,7 +160,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
if firstName == "" || lastName == "" || email == "" || password == "" || role == "" {
|
if firstName == "" || lastName == "" || email == "" || password == "" || role == "" {
|
||||||
renderRegisterError(w, "All fields are required")
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,21 +168,21 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Password hashing failed: %v", err)
|
log.Printf("Password hashing failed: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert role to int
|
// Convert role to int
|
||||||
roleID, err := strconv.Atoi(role)
|
roleID, err := strconv.Atoi(role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
renderRegisterError(w, "Invalid role")
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var adminID int
|
var adminID int
|
||||||
if roleID == 3 { // volunteer
|
if roleID == 3 { // volunteer
|
||||||
if adminCode == "" {
|
if adminCode == "" {
|
||||||
renderRegisterError(w, "Admin code is required for volunteers")
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +190,11 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = models.DB.QueryRow(`SELECT user_id FROM users WHERE role_id = 1 AND admin_code = $1`, adminCode).Scan(&adminID)
|
err = models.DB.QueryRow(`SELECT user_id FROM users WHERE role_id = 1 AND admin_code = $1`, adminCode).Scan(&adminID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
renderRegisterError(w, "Invalid admin code")
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("DB error checking admin code: %v", err)
|
log.Printf("DB error checking admin code: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +208,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
`, firstName, lastName, email, phone, string(hashedPassword), roleID).Scan(&userID)
|
`, firstName, lastName, email, phone, string(hashedPassword), roleID).Scan(&userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("User registration failed: %v", err)
|
log.Printf("User registration failed: %v", err)
|
||||||
renderRegisterError(w, "Could not create account. Email might already be in use.")
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +220,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
`, adminID, userID)
|
`, adminID, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to link volunteer to admin: %v", err)
|
log.Printf("Failed to link volunteer to admin: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,9 +229,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
func LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
clearSessionCookie(w)
|
clearSessionCookie(w)
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ func ProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
volunteernav = true
|
volunteernav = true
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "profile/profile.html", map[string]interface{}{
|
utils.Render(w, "profile.html", map[string]interface{}{
|
||||||
"Title": "My Profile",
|
"Title": "My Profile",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": adminnav,
|
"ShowAdminNav": adminnav,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func VolunteerAppointmentHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render template
|
// Render template
|
||||||
utils.Render(w, "/appointment.html", map[string]interface{}{
|
utils.Render(w, "appointment.html", map[string]interface{}{
|
||||||
"Title": "My Profile",
|
"Title": "My Profile",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": adminnav,
|
"ShowAdminNav": adminnav,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ func VolunteerPostsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
fmt.Printf("Volunteer viewing %d posts\n", len(posts))
|
||||||
|
|
||||||
utils.Render(w, "dashboard/volunteer_dashboard.html", map[string]interface{}{
|
utils.Render(w, "volunteer_dashboard.html", map[string]interface{}{
|
||||||
"Title": "Volunteer Dashboard",
|
"Title": "Volunteer Dashboard",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": showAdminNav,
|
"ShowAdminNav": showAdminNav,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.Render(w, "volunteer/poll_form.html", map[string]interface{}{
|
utils.Render(w, "poll_form.html", map[string]interface{}{
|
||||||
"Title": "Poll Questions",
|
"Title": "Poll Questions",
|
||||||
"IsAuthenticated": true,
|
"IsAuthenticated": true,
|
||||||
"ShowAdminNav": true,
|
"ShowAdminNav": true,
|
||||||
@@ -120,7 +120,6 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Insert poll response
|
// Insert poll response
|
||||||
_, err = models.DB.Exec(`
|
_, err = models.DB.Exec(`
|
||||||
INSERT INTO poll_response (
|
INSERT INTO poll_response (
|
||||||
@@ -135,6 +134,22 @@ func PollHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Print(err)
|
fmt.Print(err)
|
||||||
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
}else{
|
||||||
|
_, err := models.DB.Exec(`
|
||||||
|
UPDATE address_database
|
||||||
|
SET visited_validated = true
|
||||||
|
WHERE address_id IN (
|
||||||
|
SELECT address_id
|
||||||
|
FROM poll
|
||||||
|
WHERE poll_id = $1
|
||||||
|
)
|
||||||
|
`, pollID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Print(err)
|
||||||
|
http.Error(w, "Failed to save poll response", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, "/volunteer/Addresses", http.StatusSeeOther)
|
http.Redirect(w, r, "/volunteer/Addresses", http.StatusSeeOther)
|
||||||
|
|||||||
11
app/internal/handlers/volunteer_schedual.go
Normal file
11
app/internal/handlers/volunteer_schedual.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func VolunteerSchedualHandler(w *http.ResponseWriter, r http.Request) {
|
||||||
|
|
||||||
|
fmt.Print("Not Implementated Yet!!!")
|
||||||
|
}
|
||||||
533
app/internal/templates/address.html
Normal file
533
app/internal/templates/address.html
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="bg-white border-b border-gray-200 px-4 md:px-6 py-4">
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative w-full sm:w-auto">
|
||||||
|
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"></i>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search Addresses"
|
||||||
|
class="w-full sm:w-80 pl-10 pr-4 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination Controls -->
|
||||||
|
{{if .Pagination}}
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 w-full sm:w-auto">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
||||||
|
onclick="window.location.href='/addresses/upload-csv'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-file-import mr-2"></i>Import Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label for="pageSize" class="text-sm text-gray-600 whitespace-nowrap">Per page:</label>
|
||||||
|
<select
|
||||||
|
id="pageSize"
|
||||||
|
onchange="changePageSize(this.value)"
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="20" {{if eq .Pagination.PageSize 20}}selected{{end}}>20</option>
|
||||||
|
<option value="50" {{if eq .Pagination.PageSize 50}}selected{{end}}>50</option>
|
||||||
|
<option value="100" {{if eq .Pagination.PageSize 100}}selected{{end}}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.PreviousPage}})"
|
||||||
|
{{if not .Pagination.HasPrevious}}disabled{{end}}
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg {{if .Pagination.HasPrevious}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}} transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span class="px-3 py-2 text-sm text-gray-600 whitespace-nowrap">
|
||||||
|
{{.Pagination.CurrentPage}} / {{.Pagination.TotalPages}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick="goToPage({{.Pagination.NextPage}})"
|
||||||
|
{{if not .Pagination.HasNext}}disabled{{end}}
|
||||||
|
class="px-3 py-2 text-sm border border-gray-200 rounded-lg {{if .Pagination.HasNext}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}} transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Container -->
|
||||||
|
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Desktop Table -->
|
||||||
|
<div class="hidden lg:block overflow-x-auto">
|
||||||
|
<table class="w-full min-w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Address
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||||
|
Coordinates
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||||
|
Assigned User
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
|
||||||
|
Appointment
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
{{ range .Addresses }}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{{ if .VisitedValidated }}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
<i class="fas fa-check mr-1"></i> Valid
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
||||||
|
<i class="fas fa-times mr-1"></i> Invalid
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ .Address }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={{ .Latitude }},{{ .Longitude }}"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
({{ .Latitude }}, {{ .Longitude }})
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{{ if .UserName }}
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ .UserName }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ .UserEmail }}</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-sm text-gray-400">Unassigned</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{{ if .AppointmentDate }}
|
||||||
|
<div class="text-sm text-gray-900">{{ .AppointmentDate }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ .AppointmentTime }}</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-sm text-gray-400">No appointment</span>
|
||||||
|
{{ end }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{{ if .Assigned }}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Assigned
|
||||||
|
</button>
|
||||||
|
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||||
|
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ .UserID }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-red-400 hover:text-red-600 p-1"
|
||||||
|
title="Remove assignment"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<button
|
||||||
|
class="px-3 py-1 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors"
|
||||||
|
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||||
|
>
|
||||||
|
Assign
|
||||||
|
</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ else }}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
|
||||||
|
No addresses found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Cards -->
|
||||||
|
<div class="lg:hidden">
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
{{ range .Addresses }}
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<!-- Card Header -->
|
||||||
|
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-map-marker-alt text-gray-400"></i>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">Address</span>
|
||||||
|
</div>
|
||||||
|
{{ if .VisitedValidated }}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
<i class="fas fa-check mr-1"></i> Valid
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">
|
||||||
|
<i class="fas fa-times mr-1"></i> Invalid
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Content -->
|
||||||
|
<div class="p-4 space-y-3">
|
||||||
|
<!-- Address -->
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium text-gray-900">{{ .Address }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coordinates -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-sm text-gray-500">Coordinates</span>
|
||||||
|
<a
|
||||||
|
href="https://www.google.com/maps/search/?api=1&query={{ .Latitude }},{{ .Longitude }}"
|
||||||
|
target="_blank"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
({{ .Latitude }}, {{ .Longitude }})
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assigned User -->
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-gray-500">Assigned User</span>
|
||||||
|
<div class="text-right">
|
||||||
|
{{ if .UserName }}
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ .UserName }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ .UserEmail }}</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-sm text-gray-400">Unassigned</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Appointment -->
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="text-sm text-gray-500">Appointment</span>
|
||||||
|
<div class="text-right">
|
||||||
|
{{ if .AppointmentDate }}
|
||||||
|
<div class="text-sm text-gray-900">{{ .AppointmentDate }}</div>
|
||||||
|
<div class="text-sm text-gray-500">{{ .AppointmentTime }}</div>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-sm text-gray-400">No appointment</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-center space-x-4 pt-3 border-t border-gray-100">
|
||||||
|
{{ if .Assigned }}
|
||||||
|
<button
|
||||||
|
class="flex-1 px-4 py-2 bg-gray-100 text-gray-500 text-sm rounded-md cursor-not-allowed"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Already Assigned
|
||||||
|
</button>
|
||||||
|
<form action="/remove_assigned_address" method="POST" class="inline-block">
|
||||||
|
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
||||||
|
<input type="hidden" name="user_id" value="{{ .UserID }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1"></i> Remove
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<button
|
||||||
|
class="flex-1 px-4 py-2 bg-blue-500 text-white text-sm rounded-md hover:bg-blue-600 transition-colors font-medium"
|
||||||
|
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-plus mr-1"></i> Assign User
|
||||||
|
</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-map-marker-alt text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No addresses found</h3>
|
||||||
|
<p class="text-gray-500">Try adjusting your search criteria.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Panel Overlay -->
|
||||||
|
<div
|
||||||
|
id="assignPanelOverlay"
|
||||||
|
class="fixed inset-0 bg-black bg-opacity-50 hidden z-40"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Assign Drawer Panel -->
|
||||||
|
<div
|
||||||
|
id="assignPanel"
|
||||||
|
class="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-xl transform translate-x-full transition-transform duration-300 ease-in-out z-50 flex flex-col"
|
||||||
|
>
|
||||||
|
<!-- Panel Header -->
|
||||||
|
<div class="flex justify-between items-center px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-user-plus text-blue-500"></i>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Assign Address</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick="closeAssignPanel()"
|
||||||
|
class="text-gray-400 hover:text-gray-600 focus:outline-none p-1"
|
||||||
|
>
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel Body -->
|
||||||
|
<form id="assignForm" method="POST" action="/assign_address" class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
<input type="hidden" name="address_id" id="panelAddressID" />
|
||||||
|
|
||||||
|
<!-- Selected Address Display -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<i class="fas fa-map-marker-alt text-blue-500"></i>
|
||||||
|
<span class="font-medium text-gray-900">Selected Address:</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-700" id="panel-selected-address">None selected</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<i class="fas fa-user mr-2 text-gray-400"></i>Select User
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="user_id"
|
||||||
|
id="user_id"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">-- Select User --</option>
|
||||||
|
{{ range .Users }}
|
||||||
|
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="appointment-date" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<i class="fas fa-calendar mr-2 text-gray-400"></i>Appointment Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="appointment-date"
|
||||||
|
name="appointment_date"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
min=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Selection -->
|
||||||
|
<div>
|
||||||
|
<label for="time" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<i class="fas fa-clock mr-2 text-gray-400"></i>Appointment Time
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="time"
|
||||||
|
name="time"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Panel Footer -->
|
||||||
|
<div class="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeAssignPanel()"
|
||||||
|
class="px-6 py-2 border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
form="assignForm"
|
||||||
|
class="px-6 py-2 bg-blue-500 text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded-lg font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fas fa-check mr-2"></i> Assign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Consistent styling */
|
||||||
|
input, select, button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
|
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Generate time options in 20-minute increments
|
||||||
|
function generateTimeOptions() {
|
||||||
|
const times = [];
|
||||||
|
for (let hour = 8; hour < 18; hour++) { // Business hours 8 AM to 6 PM
|
||||||
|
for (let minute = 0; minute < 60; minute += 20) {
|
||||||
|
const timeString = String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
|
||||||
|
const displayTime = formatTime12Hour(hour, minute);
|
||||||
|
times.push({ value: timeString, display: displayTime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return times;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format time to 12-hour format
|
||||||
|
function formatTime12Hour(hour, minute) {
|
||||||
|
const ampm = hour >= 12 ? "PM" : "AM";
|
||||||
|
const displayHour = hour % 12 || 12;
|
||||||
|
return displayHour + ":" + String(minute).padStart(2, "0") + " " + ampm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate time dropdown
|
||||||
|
function populateTimeSelect() {
|
||||||
|
const timeSelect = document.getElementById("time");
|
||||||
|
const times = generateTimeOptions();
|
||||||
|
|
||||||
|
timeSelect.innerHTML = '<option value="">Select Time</option>';
|
||||||
|
times.forEach((time) => {
|
||||||
|
const option = new Option(time.display, time.value);
|
||||||
|
timeSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssignModal(addressID, address) {
|
||||||
|
document.getElementById("panelAddressID").value = addressID;
|
||||||
|
document.getElementById("panel-selected-address").textContent =
|
||||||
|
address || "Address ID: " + addressID;
|
||||||
|
|
||||||
|
// Set minimum date to today
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
document.getElementById("appointment-date").min = today;
|
||||||
|
document.getElementById("appointment-date").value = today;
|
||||||
|
|
||||||
|
// Show overlay + panel
|
||||||
|
document.getElementById("assignPanelOverlay").classList.remove("hidden");
|
||||||
|
document
|
||||||
|
.getElementById("assignPanel")
|
||||||
|
.classList.remove("translate-x-full");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.getElementById("user_id").focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignPanel() {
|
||||||
|
document
|
||||||
|
.getElementById("assignPanel")
|
||||||
|
.classList.add("translate-x-full");
|
||||||
|
document
|
||||||
|
.getElementById("assignPanelOverlay")
|
||||||
|
.classList.add("hidden");
|
||||||
|
|
||||||
|
document.getElementById("assignForm").reset();
|
||||||
|
document.getElementById("panel-selected-address").textContent =
|
||||||
|
"None selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close when clicking overlay
|
||||||
|
document
|
||||||
|
.getElementById("assignPanelOverlay")
|
||||||
|
.addEventListener("click", closeAssignPanel);
|
||||||
|
|
||||||
|
// Close on Escape key
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
closeAssignPanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("page", page);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePageSize(pageSize) {
|
||||||
|
var urlParams = new URLSearchParams(window.location.search);
|
||||||
|
urlParams.set("pageSize", pageSize);
|
||||||
|
urlParams.set("page", 1);
|
||||||
|
window.location.search = urlParams.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when page loads
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
populateTimeSelect();
|
||||||
|
|
||||||
|
// Close panel when clicking outside
|
||||||
|
document.getElementById("assignPanelOverlay").addEventListener("click", function (e) {
|
||||||
|
closeAssignPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close panel on Escape key
|
||||||
|
document.addEventListener("keydown", function(e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
const overlay = document.getElementById("assignPanelOverlay");
|
||||||
|
if (!overlay.classList.contains("invisible")) {
|
||||||
|
closeAssignPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
{{ define "content" }}
|
|
||||||
<div class="flex-1 flex flex-col overflow-hidden">
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<div class="bg-gray-50 border-b border-gray-200 px-6 py-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4 text-sm">
|
|
||||||
<div class="relative">
|
|
||||||
<i
|
|
||||||
class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-sm"
|
|
||||||
></i>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search Addresses"
|
|
||||||
class="w-full pl-8 pr-3 py-2 text-sm border border-gray-200 rounded bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{if .Pagination}}
|
|
||||||
<div class="flex items-center gap-4 text-sm">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<label for="pageSize" class="text-gray-600">Per page:</label>
|
|
||||||
<select
|
|
||||||
id="pageSize"
|
|
||||||
onchange="changePageSize(this.value)"
|
|
||||||
class="px-3 py-1 text-sm border border-gray-200 rounded bg-white"
|
|
||||||
>
|
|
||||||
<option value="20" {{if eq .Pagination.PageSize 20}}selected{{end}}>
|
|
||||||
20
|
|
||||||
</option>
|
|
||||||
<option value="50" {{if eq .Pagination.PageSize 50}}selected{{end}}>
|
|
||||||
50
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="100"
|
|
||||||
{{if
|
|
||||||
eq
|
|
||||||
.Pagination.PageSize
|
|
||||||
100}}selected{{end}}
|
|
||||||
>
|
|
||||||
100
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onclick="goToPage({{.Pagination.PreviousPage}})"
|
|
||||||
{{if
|
|
||||||
not
|
|
||||||
.Pagination.HasPrevious}}disabled{{end}}
|
|
||||||
class="px-3 py-1 text-sm border border-gray-200 rounded {{if .Pagination.HasPrevious}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-left"></i>
|
|
||||||
</button>
|
|
||||||
<span class="px-2 text-gray-600"
|
|
||||||
>{{.Pagination.CurrentPage}} / {{.Pagination.TotalPages}}</span
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onclick="goToPage({{.Pagination.NextPage}})"
|
|
||||||
{{if
|
|
||||||
not
|
|
||||||
.Pagination.HasNext}}disabled{{end}}
|
|
||||||
class="px-3 py-1 text-sm border border-gray-200 rounded {{if .Pagination.HasNext}}hover:bg-gray-50 text-gray-700{{else}}text-gray-400 cursor-not-allowed{{end}}"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chevron-right"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div
|
|
||||||
class="flex-1 overflow-x-auto overflow-y-auto bg-white border border-gray-100"
|
|
||||||
>
|
|
||||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
|
||||||
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
|
|
||||||
<tr
|
|
||||||
class="text-left text-gray-700 font-medium border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Validated</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Address</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Coordinates</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Assigned User</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Appointment</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Assign</th>
|
|
||||||
<th class="px-6 py-3 whitespace-nowrap">Remove</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200">
|
|
||||||
{{ range .Addresses }}
|
|
||||||
<tr class="hover:bg-gray-50">
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .VisitedValidated }}
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check mr-1"></i> Valid
|
|
||||||
</span>
|
|
||||||
{{ else }}
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times mr-1"></i> Invalid
|
|
||||||
</span>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">{{ .Address }}</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
<a
|
|
||||||
href="https://www.google.com/maps/search/?api=1&query={{ .Latitude }},{{ .Longitude }}"
|
|
||||||
target="_blank"
|
|
||||||
class="text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
({{ .Latitude }}, {{ .Longitude }})
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .UserName }}{{ .UserName }}<br /><span
|
|
||||||
class="text-xs text-gray-500"
|
|
||||||
>{{ .UserEmail }}</span
|
|
||||||
>{{ else }}<span class="text-gray-400">Unassigned</span>{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .AppointmentDate }} {{ .AppointmentDate }} {{ .AppointmentTime
|
|
||||||
}} {{ else }}
|
|
||||||
<span class="text-gray-400">No appointment</span>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .Assigned }}
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 bg-gray-400 text-white text-sm cursor-not-allowed"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
Assigned
|
|
||||||
</button>
|
|
||||||
{{ else }}
|
|
||||||
<button
|
|
||||||
class="px-3 py-1 bg-blue-600 text-white text-sm hover:bg-blue-700"
|
|
||||||
onclick="openAssignModal({{ .AddressID }}, '{{ .Address }}')"
|
|
||||||
>
|
|
||||||
Assign
|
|
||||||
</button>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-3 whitespace-nowrap">
|
|
||||||
{{ if .Assigned }}
|
|
||||||
<form
|
|
||||||
action="/remove_assigned_address"
|
|
||||||
method="POST"
|
|
||||||
class="inline-block"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="address_id" value="{{ .AddressID }}" />
|
|
||||||
<input type="hidden" name="user_id" value="{{ .UserID }}" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="text-red-600 hover:text-red-800 font-medium text-xs px-2 py-1 hover:bg-red-50 transition-colors"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{{ else }}
|
|
||||||
<span class="text-gray-400 text-xs">-</span>
|
|
||||||
{{ end }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ else }}
|
|
||||||
<tr>
|
|
||||||
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
|
|
||||||
No addresses found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{ end }}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assign Modal -->
|
|
||||||
<div
|
|
||||||
id="assignModal"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"
|
|
||||||
>
|
|
||||||
<div class="bg-white w-full max-w-lg mx-4 shadow-lg">
|
|
||||||
<!-- Modal Header -->
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center px-6 py-4 border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Assign Address</h2>
|
|
||||||
<button
|
|
||||||
onclick="closeAssignModal()"
|
|
||||||
class="text-gray-400 hover:text-gray-600 focus:outline-none"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times text-xl"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Body -->
|
|
||||||
<form
|
|
||||||
id="assignForm"
|
|
||||||
method="POST"
|
|
||||||
action="/assign_address"
|
|
||||||
class="p-6 space-y-4"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="address_id" id="modalAddressID" />
|
|
||||||
|
|
||||||
<!-- Selected Address Display -->
|
|
||||||
<div class="bg-gray-50 p-3 border border-gray-200">
|
|
||||||
<div class="flex items-center space-x-2 mb-4">
|
|
||||||
<i class="fas fa-map-marker-alt text-gray-500"></i>
|
|
||||||
<span class="font-medium text-gray-900">Selected Address:</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-700 mb-4" id="selected-address">
|
|
||||||
None selected
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Selection -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label
|
|
||||||
for="user_id"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-user mr-2"></i>Select User
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="user_id"
|
|
||||||
id="user_id"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">-- Select User --</option>
|
|
||||||
{{ range .Users }}
|
|
||||||
<option value="{{ .ID }}">{{ .Name }}</option>
|
|
||||||
{{ end }}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Selection -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<label
|
|
||||||
for="appointment-date"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-calendar mr-2"></i>Appointment Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
id="appointment-date"
|
|
||||||
name="appointment_date"
|
|
||||||
required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
min=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Time Selection -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<!-- Start Time -->
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="time"
|
|
||||||
class="block text-sm font-medium text-gray-700 mb-2"
|
|
||||||
>
|
|
||||||
<i class="fas fa-clock mr-2"></i>Time
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="time"
|
|
||||||
name="time"
|
|
||||||
required
|
|
||||||
onchange="updateEndTime()"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">Select Time</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Actions -->
|
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick="closeAssignModal()"
|
|
||||||
class="px-4 py-2 border border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
<i class="fas fa-check mr-2"></i> Assign
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Square corners across UI */
|
|
||||||
* {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Generate time options in 10-minute increments
|
|
||||||
function generateTimeOptions() {
|
|
||||||
const times = [];
|
|
||||||
for (let hour = 0; hour < 24; hour++) {
|
|
||||||
for (let minute = 0; minute < 60; minute += 20) {
|
|
||||||
const timeString =
|
|
||||||
String(hour).padStart(2, "0") + ":" + String(minute).padStart(2, "0");
|
|
||||||
const displayTime = formatTime12Hour(hour, minute);
|
|
||||||
times.push({ value: timeString, display: displayTime });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return times;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format time to 12-hour format
|
|
||||||
function formatTime12Hour(hour, minute) {
|
|
||||||
const ampm = hour >= 12 ? "PM" : "AM";
|
|
||||||
const displayHour = hour % 12 || 12;
|
|
||||||
return displayHour + ":" + String(minute).padStart(2, "0") + " " + ampm;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate time dropdown
|
|
||||||
function populateTimeSelect() {
|
|
||||||
const timeSelect = document.getElementById("time");
|
|
||||||
const times = generateTimeOptions();
|
|
||||||
|
|
||||||
timeSelect.innerHTML = '<option value="">Select Time</option>';
|
|
||||||
times.forEach((time) => {
|
|
||||||
const option = new Option(time.display, time.value);
|
|
||||||
timeSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAssignModal(addressID, address) {
|
|
||||||
document.getElementById("modalAddressID").value = addressID;
|
|
||||||
document.getElementById("selected-address").textContent =
|
|
||||||
address || "Address ID: " + addressID;
|
|
||||||
|
|
||||||
// Set minimum date to today
|
|
||||||
const today = new Date().toISOString().split("T")[0];
|
|
||||||
document.getElementById("appointment-date").min = today;
|
|
||||||
document.getElementById("appointment-date").value = today;
|
|
||||||
|
|
||||||
document.getElementById("assignModal").classList.remove("hidden");
|
|
||||||
document.getElementById("assignModal").classList.add("flex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAssignModal() {
|
|
||||||
document.getElementById("assignModal").classList.remove("flex");
|
|
||||||
document.getElementById("assignModal").classList.add("hidden");
|
|
||||||
document.getElementById("assignForm").reset();
|
|
||||||
document.getElementById("selected-address").textContent = "None selected";
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToPage(page) {
|
|
||||||
var urlParams = new URLSearchParams(window.location.search);
|
|
||||||
urlParams.set("page", page);
|
|
||||||
window.location.search = urlParams.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function changePageSize(pageSize) {
|
|
||||||
var urlParams = new URLSearchParams(window.location.search);
|
|
||||||
urlParams.set("pageSize", pageSize);
|
|
||||||
urlParams.set("page", 1);
|
|
||||||
window.location.search = urlParams.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when page loads
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
populateTimeSelect();
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
document
|
|
||||||
.getElementById("assignModal")
|
|
||||||
.addEventListener("click", function (e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeAssignModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{{ end }}
|
|
||||||
@@ -18,10 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="/dashboard"
|
href="/addresses"
|
||||||
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded inline-flex items-center"
|
class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded inline-flex items-center"
|
||||||
>
|
>
|
||||||
<i class="fas fa-arrow-left mr-2"></i>Back to Dashboard
|
<i class="fas fa-arrow-left mr-2"></i>Back to Addresses
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
387
app/internal/templates/dashboard.html
Normal file
387
app/internal/templates/dashboard.html
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
|
||||||
|
/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#single-map {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 80px); /* Account for header height */
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide map controls when sidebar is active on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#single-map {
|
||||||
|
height: 50vh; /* Smaller height on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.sidebar-open .map-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.control-button:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-popup {
|
||||||
|
position: absolute;
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
bottom: 12px;
|
||||||
|
left: -50px;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-popup:after {
|
||||||
|
top: 100%;
|
||||||
|
border: solid transparent;
|
||||||
|
content: " ";
|
||||||
|
height: 0;
|
||||||
|
width: 0;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-width: 10px;
|
||||||
|
left: 48px;
|
||||||
|
margin-left: -10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure OpenLayers controls stay below sidebar */
|
||||||
|
.ol-control {
|
||||||
|
z-index: 150 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide popup when sidebar is open on mobile */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body.sidebar-open .ol-popup {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard layout */
|
||||||
|
.dashboard-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 80px); /* Account for header */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.dashboard-container {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#single-map {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-section {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
min-height: 50vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-section {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.stats-section {
|
||||||
|
width: 20rem;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Dashboard Layout -->
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Left: Map -->
|
||||||
|
<div class="map-section bg-white">
|
||||||
|
<div class="map-controls">
|
||||||
|
<button class="control-button" onclick="refreshMap()" title="Refresh Map">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="control-button" onclick="fitAllMarkers()" title="Fit All Markers">
|
||||||
|
<i class="fas fa-expand-arrows-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button class="control-button" onclick="clearAllMarkers()" title="Clear All Markers">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="single-map"></div>
|
||||||
|
|
||||||
|
<div id="popup" class="ol-popup">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
id="popup-closer"
|
||||||
|
class="absolute top-1 right-2 text-gray-500 hover:text-gray-800"
|
||||||
|
>×</a
|
||||||
|
>
|
||||||
|
<div id="popup-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Stats -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||||
|
<div class="w-10 h-10 bg-blue-50 flex items-center justify-center rounded">
|
||||||
|
<i class="fas fa-users text-blue-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-gray-600">Active Volunteers</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900">{{.VolunteerCount}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||||
|
<div class="w-10 h-10 bg-green-50 flex items-center justify-center rounded">
|
||||||
|
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-gray-600">Addresses Visited</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900">{{.ValidatedCount}}</p>
|
||||||
|
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||||
|
<div class="w-10 h-10 bg-yellow-50 flex items-center justify-center rounded">
|
||||||
|
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-gray-600">Donation</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900">${{.TotalDonations}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-4 flex items-center">
|
||||||
|
<div class="w-10 h-10 bg-red-50 flex items-center justify-center rounded">
|
||||||
|
<i class="fas fa-percentage text-red-600 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-gray-600">Houses Left</p>
|
||||||
|
<p class="text-xl font-bold text-gray-900">{{.HousesLeftPercent}}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Global variables - only one set
|
||||||
|
let theMap = null;
|
||||||
|
let markerLayer = null;
|
||||||
|
let popup = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
// Clean initialization
|
||||||
|
function initializeMap() {
|
||||||
|
if (initialized || !window.ol) {
|
||||||
|
console.log("Map already initialized or OpenLayers not ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Initializing single map...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calgary coordinates
|
||||||
|
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
||||||
|
|
||||||
|
// Create the ONE AND ONLY map
|
||||||
|
theMap = new ol.Map({
|
||||||
|
target: "single-map",
|
||||||
|
layers: [
|
||||||
|
new ol.layer.Tile({
|
||||||
|
source: new ol.source.OSM(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
view: new ol.View({
|
||||||
|
center: center,
|
||||||
|
zoom: 11,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create popup
|
||||||
|
popup = new ol.Overlay({
|
||||||
|
element: document.getElementById("popup"),
|
||||||
|
positioning: "bottom-center",
|
||||||
|
stopEvent: false,
|
||||||
|
offset: [0, -50],
|
||||||
|
});
|
||||||
|
theMap.addOverlay(popup);
|
||||||
|
|
||||||
|
// Close popup handler
|
||||||
|
document.getElementById("popup-closer").onclick = function () {
|
||||||
|
popup.setPosition(undefined);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create marker layer
|
||||||
|
markerLayer = new ol.layer.Vector({
|
||||||
|
source: new ol.source.Vector(),
|
||||||
|
style: new ol.style.Style({
|
||||||
|
text: new ol.style.Text({
|
||||||
|
text: "📍",
|
||||||
|
font: "24px sans-serif",
|
||||||
|
fill: new ol.style.Fill({ color: "#EF4444" }),
|
||||||
|
offsetY: -12,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
theMap.addLayer(markerLayer);
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
theMap.on("click", function (event) {
|
||||||
|
const feature = theMap.forEachFeatureAtPixel(
|
||||||
|
event.pixel,
|
||||||
|
function (feature) {
|
||||||
|
return feature;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (feature && feature.get("address_data")) {
|
||||||
|
const data = feature.get("address_data");
|
||||||
|
document.getElementById("popup-content").innerHTML = `
|
||||||
|
<div class="text-sm">
|
||||||
|
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
||||||
|
<p><strong>Address:</strong> ${data.address}</p>
|
||||||
|
<p><strong>House #:</strong> ${data.house_number}</p>
|
||||||
|
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
||||||
|
<p><strong>ID:</strong> ${data.address_id}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
popup.setPosition(event.coordinate);
|
||||||
|
} else {
|
||||||
|
popup.setPosition(undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
console.log("Map initialized successfully");
|
||||||
|
|
||||||
|
// Load markers
|
||||||
|
setTimeout(loadMarkers, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Map initialization error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load validated addresses
|
||||||
|
async function loadMarkers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/validated-addresses");
|
||||||
|
const addresses = await response.json();
|
||||||
|
|
||||||
|
console.log(`Loading ${addresses.length} addresses`);
|
||||||
|
document.getElementById("marker-count").textContent = `${addresses.length} on map`;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
markerLayer.getSource().clear();
|
||||||
|
|
||||||
|
// Add new markers
|
||||||
|
const features = [];
|
||||||
|
addresses.forEach((addr) => {
|
||||||
|
if (addr.longitude && addr.latitude) {
|
||||||
|
const coords = ol.proj.fromLonLat([addr.longitude, addr.latitude]);
|
||||||
|
const feature = new ol.Feature({
|
||||||
|
geometry: new ol.geom.Point(coords),
|
||||||
|
address_data: addr,
|
||||||
|
});
|
||||||
|
features.push(feature);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
markerLayer.getSource().addFeatures(features);
|
||||||
|
|
||||||
|
if (features.length > 0) {
|
||||||
|
const extent = markerLayer.getSource().getExtent();
|
||||||
|
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading markers:", error);
|
||||||
|
document.getElementById("marker-count").textContent = "Error loading";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control functions
|
||||||
|
function refreshMap() {
|
||||||
|
loadMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAllMarkers() {
|
||||||
|
if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
|
||||||
|
const extent = markerLayer.getSource().getExtent();
|
||||||
|
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllMarkers() {
|
||||||
|
if (markerLayer) {
|
||||||
|
markerLayer.getSource().clear();
|
||||||
|
}
|
||||||
|
if (popup) {
|
||||||
|
popup.setPosition(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when ready
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
setTimeout(initializeMap, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for sidebar state changes to manage map controls visibility
|
||||||
|
function handleSidebarToggle() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
|
if (sidebar && sidebar.classList.contains('active')) {
|
||||||
|
body.classList.add('sidebar-open');
|
||||||
|
} else {
|
||||||
|
body.classList.remove('sidebar-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the original toggleSidebar function to handle map controls
|
||||||
|
if (typeof window.toggleSidebar === 'function') {
|
||||||
|
const originalToggleSidebar = window.toggleSidebar;
|
||||||
|
window.toggleSidebar = function() {
|
||||||
|
originalToggleSidebar();
|
||||||
|
setTimeout(handleSidebarToggle, 50);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{ end }}
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
{{ define "content" }}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{{.Title}}</title>
|
|
||||||
<link
|
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://cdn.jsdelivr.net/npm/ol@7.5.2/ol.css"
|
|
||||||
/>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/ol@7.5.2/dist/ol.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* CRITICAL: Prevent any duplicate maps */
|
|
||||||
.ol-viewport {
|
|
||||||
max-width: 100% !important;
|
|
||||||
max-height: 700px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#single-map {
|
|
||||||
width: 100%;
|
|
||||||
height: 700px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-button {
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ol-popup {
|
|
||||||
position: absolute;
|
|
||||||
background-color: white;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
bottom: 12px;
|
|
||||||
left: -50px;
|
|
||||||
min-width: 200px;
|
|
||||||
max-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ol-popup:after {
|
|
||||||
top: 100%;
|
|
||||||
border: solid transparent;
|
|
||||||
content: " ";
|
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
border-color: rgba(255, 255, 255, 0);
|
|
||||||
border-top-color: #ffffff;
|
|
||||||
border-width: 10px;
|
|
||||||
left: 48px;
|
|
||||||
margin-left: -10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50">
|
|
||||||
<!-- Navigation -->
|
|
||||||
<div class="bg-white border-b border-gray-200 w-full">
|
|
||||||
<div class="px-8 py-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-8 h-8 bg-blue-600 flex items-center justify-center">
|
|
||||||
<i class="fas fa-chart-bar text-white text-sm"></i>
|
|
||||||
</div>
|
|
||||||
<span class="text-xl font-semibold text-gray-900"
|
|
||||||
>Dashboard Overview</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<button
|
|
||||||
class="px-6 py-2.5 bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
||||||
onclick="refreshMap()"
|
|
||||||
>
|
|
||||||
<i class="fas fa-sync-alt mr-2"></i>Refresh Map
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="px-6 py-2.5 bg-green-600 text-white text-sm font-medium hover:bg-green-700 transition-colors"
|
|
||||||
onclick="window.location.href='/addresses/upload-csv'"
|
|
||||||
>
|
|
||||||
<i class="fas fa-upload mr-2"></i>Import Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 bg-white border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<div class="border-r border-gray-200 p-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-12 h-12 bg-blue-50 flex items-center justify-center">
|
|
||||||
<i class="fas fa-users text-blue-600 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 mb-1">
|
|
||||||
Active Volunteers
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">{{.VolunteerCount}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-r border-gray-200 p-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-12 h-12 bg-green-50 flex items-center justify-center">
|
|
||||||
<i class="fas fa-map-marker-alt text-green-600 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 mb-1">
|
|
||||||
Addresses Visited
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">{{.ValidatedCount}}</p>
|
|
||||||
<p id="marker-count" class="text-xs text-gray-500">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-r border-gray-200 p-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-12 h-12 bg-yellow-50 flex items-center justify-center">
|
|
||||||
<i class="fas fa-dollar-sign text-yellow-600 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 mb-1">Donation</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">${{.TotalDonations}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-8">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-12 h-12 bg-red-50 flex items-center justify-center">
|
|
||||||
<i class="fas fa-percentage text-red-600 text-lg"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-600 mb-1">Houses Left</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
{{.HousesLeftPercent}}%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SINGLE MAP SECTION -->
|
|
||||||
<div class="bg-white w-full relative">
|
|
||||||
<div class="map-controls">
|
|
||||||
<button class="control-button" onclick="refreshMap()">
|
|
||||||
<i class="fas fa-sync-alt"></i> Refresh
|
|
||||||
</button>
|
|
||||||
<button class="control-button" onclick="fitAllMarkers()">
|
|
||||||
<i class="fas fa-expand-arrows-alt"></i> Fit All
|
|
||||||
</button>
|
|
||||||
<button class="control-button" onclick="clearAllMarkers()">
|
|
||||||
<i class="fas fa-trash"></i> Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- THIS IS THE ONLY MAP CONTAINER -->
|
|
||||||
<div id="single-map"></div>
|
|
||||||
|
|
||||||
<div id="popup" class="ol-popup">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
id="popup-closer"
|
|
||||||
style="
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
text-decoration: none;
|
|
||||||
"
|
|
||||||
>×</a
|
|
||||||
>
|
|
||||||
<div id="popup-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Global variables - only one set
|
|
||||||
let theMap = null;
|
|
||||||
let markerLayer = null;
|
|
||||||
let popup = null;
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
// Clean initialization
|
|
||||||
function initializeMap() {
|
|
||||||
if (initialized || !window.ol) {
|
|
||||||
console.log("Map already initialized or OpenLayers not ready");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Initializing single map...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Calgary coordinates
|
|
||||||
const center = ol.proj.fromLonLat([-114.0719, 51.0447]);
|
|
||||||
|
|
||||||
// Create the ONE AND ONLY map
|
|
||||||
theMap = new ol.Map({
|
|
||||||
target: "single-map",
|
|
||||||
layers: [
|
|
||||||
new ol.layer.Tile({
|
|
||||||
source: new ol.source.OSM(),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
view: new ol.View({
|
|
||||||
center: center,
|
|
||||||
zoom: 11,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create popup
|
|
||||||
popup = new ol.Overlay({
|
|
||||||
element: document.getElementById("popup"),
|
|
||||||
positioning: "bottom-center",
|
|
||||||
stopEvent: false,
|
|
||||||
offset: [0, -50],
|
|
||||||
});
|
|
||||||
theMap.addOverlay(popup);
|
|
||||||
|
|
||||||
// Close popup handler
|
|
||||||
document.getElementById("popup-closer").onclick = function () {
|
|
||||||
popup.setPosition(undefined);
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create marker layer
|
|
||||||
markerLayer = new ol.layer.Vector({
|
|
||||||
source: new ol.source.Vector(),
|
|
||||||
style: new ol.style.Style({
|
|
||||||
text: new ol.style.Text({
|
|
||||||
text: "📍",
|
|
||||||
font: "24px sans-serif",
|
|
||||||
fill: new ol.style.Fill({ color: "#EF4444" }),
|
|
||||||
offsetY: -12, // Adjust vertical position so pin points to location
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
theMap.addLayer(markerLayer);
|
|
||||||
|
|
||||||
// Click handler
|
|
||||||
theMap.on("click", function (event) {
|
|
||||||
const feature = theMap.forEachFeatureAtPixel(
|
|
||||||
event.pixel,
|
|
||||||
function (feature) {
|
|
||||||
return feature;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (feature && feature.get("address_data")) {
|
|
||||||
const data = feature.get("address_data");
|
|
||||||
document.getElementById("popup-content").innerHTML = `
|
|
||||||
<div class="text-sm">
|
|
||||||
<h4 class="font-semibold text-gray-900 mb-2">Address Details</h4>
|
|
||||||
<p><strong>Address:</strong> ${data.address}</p>
|
|
||||||
<p><strong>House #:</strong> ${data.house_number}</p>
|
|
||||||
<p><strong>Street:</strong> ${data.street_name} ${data.street_type}</p>
|
|
||||||
<p><strong>ID:</strong> ${data.address_id}</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
popup.setPosition(event.coordinate);
|
|
||||||
} else {
|
|
||||||
popup.setPosition(undefined);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
initialized = true;
|
|
||||||
console.log("Map initialized successfully");
|
|
||||||
|
|
||||||
// Load markers
|
|
||||||
setTimeout(loadMarkers, 500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Map initialization error:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load validated addresses
|
|
||||||
async function loadMarkers() {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/validated-addresses");
|
|
||||||
const addresses = await response.json();
|
|
||||||
|
|
||||||
console.log(`Loading ${addresses.length} addresses`);
|
|
||||||
document.getElementById(
|
|
||||||
"marker-count"
|
|
||||||
).textContent = `${addresses.length} on map`;
|
|
||||||
|
|
||||||
// Clear existing markers
|
|
||||||
markerLayer.getSource().clear();
|
|
||||||
|
|
||||||
// Add new markers
|
|
||||||
const features = [];
|
|
||||||
addresses.forEach((addr) => {
|
|
||||||
if (addr.longitude && addr.latitude) {
|
|
||||||
const coords = ol.proj.fromLonLat([
|
|
||||||
addr.longitude,
|
|
||||||
addr.latitude,
|
|
||||||
]);
|
|
||||||
const feature = new ol.Feature({
|
|
||||||
geometry: new ol.geom.Point(coords),
|
|
||||||
address_data: addr,
|
|
||||||
});
|
|
||||||
features.push(feature);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
markerLayer.getSource().addFeatures(features);
|
|
||||||
|
|
||||||
if (features.length > 0) {
|
|
||||||
const extent = markerLayer.getSource().getExtent();
|
|
||||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading markers:", error);
|
|
||||||
document.getElementById("marker-count").textContent = "Error loading";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control functions
|
|
||||||
function refreshMap() {
|
|
||||||
loadMarkers();
|
|
||||||
}
|
|
||||||
|
|
||||||
function fitAllMarkers() {
|
|
||||||
if (markerLayer && markerLayer.getSource().getFeatures().length > 0) {
|
|
||||||
const extent = markerLayer.getSource().getExtent();
|
|
||||||
theMap.getView().fit(extent, { padding: [20, 20, 20, 20] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAllMarkers() {
|
|
||||||
if (markerLayer) {
|
|
||||||
markerLayer.getSource().clear();
|
|
||||||
}
|
|
||||||
if (popup) {
|
|
||||||
popup.setPosition(undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when ready
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
setTimeout(initializeMap, 1000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{ end }}
|
|
||||||
@@ -9,615 +9,479 @@
|
|||||||
<title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title>
|
<title>{{if .Title}}{{.Title}}{{else}}Poll System{{end}}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="//unpkg.com/alpinejs" defer></script>
|
<script src="//unpkg.com/alpinejs" defer></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
||||||
|
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||||
/>
|
/>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"custom-gray": "#f8fafc",
|
||||||
|
"sidebar-gray": "#ffffff",
|
||||||
|
"border-gray": "#e2e8f0",
|
||||||
|
"text-primary": "#1e293b",
|
||||||
|
"text-secondary": "#64748b",
|
||||||
|
"blue-primary": "#3b82f6",
|
||||||
|
"blue-light": "#eff6ff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile sidebar overlay */
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop layout - sidebar always visible */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content-container {
|
||||||
|
margin-left: 240px; /* Match sidebar width */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile sidebar positioning */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar.active {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content-container {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure sidebar has fixed positioning on desktop */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
#sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbars but keep functionality */
|
||||||
|
.hide-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.hide-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 font-sans">
|
<body class="bg-custom-gray">
|
||||||
{{ if .IsAuthenticated }}
|
{{ if .IsAuthenticated }}
|
||||||
<!-- Authenticated User Interface -->
|
<!-- Authenticated User Interface -->
|
||||||
<div class="min-h-screen" x-data="{ mobileMenuOpen: false }">
|
<div class="min-h-screen">
|
||||||
|
|
||||||
<!-- Simple Navigation Bar -->
|
<!-- Mobile sidebar overlay -->
|
||||||
<nav class="bg-gray-700 border-b border-gray-600 fixed top-0 left-0 w-full z-50">
|
<div id="sidebar-overlay" class="sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||||
<div class="max-w-9xl mx-auto px-4 sm:px-6 lg:px-8 ">
|
|
||||||
<div class="flex justify-between items-center h-14 ">
|
|
||||||
|
|
||||||
<!-- Left: Logo and Navigation Links -->
|
|
||||||
<div class="flex items-center space-x-12">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
|
||||||
<span class="text-xl font-semibold text-white">Poll System</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div id="sidebar" class="w-60 bg-sidebar-gray border-r border-border-gray flex flex-col">
|
||||||
|
<!-- Logo/Header -->
|
||||||
|
<div class="flex items-center justify-between p-6 border-b border-border-gray">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-7 h-7 bg-blue-primary rounded-full flex items-center justify-center mr-3">
|
||||||
|
<img src="../../static/icon-512.png" alt="Logo" class="w-4 h-4"/>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="font-semibold text-text-primary text-base">Poll System</span>
|
||||||
|
</div>
|
||||||
|
<!-- Mobile close button -->
|
||||||
|
<button id="sidebar-close" class="md:hidden text-text-secondary hover:text-text-primary" onclick="toggleSidebar()">
|
||||||
|
<i class="fas fa-times text-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Navigation Links -->
|
<!-- Navigation -->
|
||||||
<div class="hidden md:flex items-center space-x-10">
|
<nav class="flex-1 py-4">
|
||||||
{{ if .ShowAdminNav }}
|
<div class="space-y-1 px-3">
|
||||||
<a href="/dashboard" class="text-sm font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
{{ if .ShowAdminNav }}
|
||||||
Dashboard
|
<a href="/dashboard" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "dashboard"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
</a>
|
<i class="fas fa-chart-pie w-5 {{if eq .ActiveSection "dashboard"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
<a href="/volunteers" class="text-sm font-medium {{if eq .ActiveSection "volunteer"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<span {{if eq .ActiveSection "dashboard"}}class="font-medium"{{end}}>Dashboard</span>
|
||||||
Volunteers
|
</a>
|
||||||
</a>
|
<a href="/volunteers" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "volunteer"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
<a href="/team_builder" class="text-sm font-medium {{if eq .ActiveSection "team_builder"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<i class="fas fa-users w-5 {{if eq .ActiveSection "volunteer"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
Team Builder
|
<span {{if eq .ActiveSection "volunteer"}}class="font-medium"{{end}}>Volunteers</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<a href="/team_builder" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "team_builder"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
Addresses
|
<i class="fas fa-user-friends w-5 {{if eq .ActiveSection "team_builder"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
</a>
|
<span {{if eq .ActiveSection "team_builder"}}class="font-medium"{{end}}>Team Builder</span>
|
||||||
<a href="/posts" class="text-sm font-medium {{if eq .ActiveSection "post"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
</a>
|
||||||
Posts
|
<a href="/addresses" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "address"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
</a>
|
<i class="fas fa-map-marked-alt w-5 {{if eq .ActiveSection "address"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
<a href="/reports" class="text-sm font-medium {{if eq .ActiveSection "report"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<span {{if eq .ActiveSection "address"}}class="font-medium"{{end}}>Addresses</span>
|
||||||
Reports
|
</a>
|
||||||
</a>
|
<a href="/posts" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "posts"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
{{ end }}
|
<i class="fas fa-list w-5 {{if eq .ActiveSection "posts"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
|
<span {{if eq .ActiveSection "posts"}}class="font-medium"{{end}}>Posts</span>
|
||||||
|
</a>
|
||||||
|
<a href="/reports" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "reports"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
|
<i class="fas fa-table w-5 {{if eq .ActiveSection "reports"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
|
<span {{if eq .ActiveSection "reports"}}class="font-medium"{{end}}>Reports</span>
|
||||||
|
</a>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
{{ if .ShowVolunteerNav }}
|
{{ if .ShowVolunteerNav }}
|
||||||
<a href="/volunteer/dashboard" class="text-sm font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<a href="/volunteer/dashboard" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "dashboard"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
Dashboard
|
<i class="fas fa-chart-pie w-5 {{if eq .ActiveSection "dashboard"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
</a>
|
<span {{if eq .ActiveSection "dashboard"}}class="font-medium"{{end}}>Dashboard</span>
|
||||||
<a href="/volunteer/Addresses" class="text-sm font-medium {{if eq .ActiveSection "address"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
</a>
|
||||||
Assigned Address
|
<a href="/volunteer/Addresses" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "address"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
</a>
|
<i class="fas fa-home w-5 {{if eq .ActiveSection "address"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
<!-- <a href="/volunteer/schedual" class="text-sm font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<span {{if eq .ActiveSection "address"}}class="font-medium"{{end}}>Assigned Address</span>
|
||||||
My Schedule
|
</a>
|
||||||
</a> -->
|
{{ end }}
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<a href="/profile" class="text-sm font-medium {{if eq .ActiveSection "profile"}}text-blue-300 border-b-2 border-blue-300{{else}}text-gray-300 hover:text-white{{end}} py-3 px-1">
|
<a href="/profile" class="flex items-center px-3 py-2.5 text-sm {{if eq .ActiveSection "profile"}}bg-blue-light text-blue-primary border-r-4 border-blue-primary pl-2 rounded-none{{else}}text-text-secondary hover:bg-gray-50 rounded-md{{end}} group">
|
||||||
Profile
|
<i class="fas fa-user-circle w-5 {{if eq .ActiveSection "profile"}}text-blue-primary{{else}}text-gray-400{{end}} mr-3"></i>
|
||||||
</a>
|
<span {{if eq .ActiveSection "profile"}}class="font-medium"{{end}}>Profile</span>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<!-- Right: User Info and Actions -->
|
<a href="/logout" class="flex items-center px-3 py-2.5 text-sm text-text-secondary hover:bg-gray-50 rounded-md group">
|
||||||
<div class="flex items-center space-x-4">
|
<i class="fas fa-sign-out-alt w-5 text-gray-400 mr-3"></i>
|
||||||
<!-- User Avatar and Name -->
|
<span>Logout</span>
|
||||||
<div class="hidden sm:flex items-center space-x-3">
|
</a>
|
||||||
<span class="text-sm text-gray-300">{{.UserName}}</span>
|
</div>
|
||||||
<div class="w-8 h-8 bg-blue-500 flex items-center justify-center text-white font-medium rounded-full">
|
</nav>
|
||||||
{{slice .UserName 0 1}}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Logout Button -->
|
<!-- Main Content Container -->
|
||||||
<a href="/logout" class="hidden sm:flex items-center px-3 py-2 text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-600 rounded-md">
|
<div class="main-content-container min-h-screen flex flex-col bg-custom-gray">
|
||||||
<i class="fas fa-sign-out-alt mr-2"></i>
|
<!-- Top Header -->
|
||||||
Logout
|
<div class="bg-white border-b border-border-gray px-4 md:px-6 py-4">
|
||||||
</a>
|
<div class="flex items-center justify-between">
|
||||||
|
<!-- Hamburger (left aligned with consistent spacing) -->
|
||||||
<!-- Mobile Menu Button -->
|
<div class="flex items-center">
|
||||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 rounded-md text-gray-300 hover:text-white hover:bg-gray-600">
|
<button id="menu-toggle" class="md:hidden text-text-secondary hover:text-text-primary p-2 -ml-2" onclick="toggleSidebar()">
|
||||||
<i class="fas fa-bars" x-show="!mobileMenuOpen"></i>
|
<i class="fas fa-bars text-lg"></i>
|
||||||
<i class="fas fa-times" x-show="mobileMenuOpen"></i>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Navigation Menu -->
|
<!-- Right side -->
|
||||||
<div x-show="mobileMenuOpen"
|
<div class="flex items-center space-x-2 md:space-x-4">
|
||||||
x-transition:enter="transition ease-out duration-200"
|
<!-- Dark mode -->
|
||||||
x-transition:enter-start="opacity-0 scale-95"
|
<button class="text-text-secondary hover:text-text-primary p-2">
|
||||||
x-transition:enter-end="opacity-100 scale-100"
|
<i class="fas fa-moon text-lg"></i>
|
||||||
x-transition:leave="transition ease-in duration-150"
|
</button>
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
<!-- Profile (hover dropdown on desktop, click on mobile) -->
|
||||||
class="md:hidden border-t border-gray-600"
|
<div class="relative group cursor-pointer">
|
||||||
@click.outside="mobileMenuOpen = false">
|
<div class="flex items-center space-x-2 md:space-x-3" onclick="toggleProfileMenu()">
|
||||||
|
<span class="text-sm font-medium text-text-primary hidden sm:block">{{.UserName}}</span>
|
||||||
<div class="px-4 py-4 bg-gray-800">
|
<div class="w-8 h-8 rounded-full bg-blue-primary flex items-center justify-center text-white font-medium">
|
||||||
<!-- User Info Mobile -->
|
{{slice .UserName 0 1}}
|
||||||
<div class="flex items-center space-x-3 pb-4 mb-4 border-b border-gray-600">
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500 flex items-center justify-center text-white font-medium rounded-full">
|
|
||||||
{{slice .UserName 0 1}}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-white">{{.UserName}}</p>
|
|
||||||
<p class="text-xs text-gray-400">Logged in</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Navigation Links -->
|
<!-- Dropdown -->
|
||||||
<div class="space-y-2">
|
<div id="profile-menu" class="absolute right-0 mt-2 w-40 bg-white border border-border-gray rounded-md shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
|
||||||
{{ if .ShowAdminNav }}
|
<a href="/profile" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Profile</a>
|
||||||
<a href="/dashboard" @click="mobileMenuOpen = false"
|
<a href="#" class="block px-4 py-2 text-sm text-text-primary hover:bg-gray-100">Settings</a>
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
<a href="/logout" class="block px-4 py-2 text-sm text-red-600 hover:bg-gray-100">Logout</a>
|
||||||
<i class="fas fa-chart-pie mr-3 w-4"></i>Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/volunteers" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "volunteer"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-users mr-3 w-4"></i>Volunteers
|
|
||||||
</a>
|
|
||||||
<a href="/team_builder" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "team_builder"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-user-friends mr-3 w-4"></i>Team Builder
|
|
||||||
</a>
|
|
||||||
<a href="/addresses" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "address"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-map-marked-alt mr-3 w-4"></i>Addresses
|
|
||||||
</a>
|
|
||||||
<a href="/posts" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "post"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-blog mr-3 w-4"></i>Posts
|
|
||||||
</a>
|
|
||||||
<a href="/reports" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "report"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-table mr-3 w-4"></i>Reports
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if .ShowVolunteerNav }}
|
|
||||||
<a href="/volunteer/dashboard" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "dashboard"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-chart-pie mr-3 w-4"></i>Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/volunteer/Addresses" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "address"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-home mr-3 w-4"></i>Assigned Address
|
|
||||||
</a>
|
|
||||||
<a href="/volunteer/schedual" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "schedual"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-calendar-alt mr-3 w-4"></i>My Schedule
|
|
||||||
</a>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<a href="/profile" @click="mobileMenuOpen = false"
|
|
||||||
class="block px-3 py-2 rounded-md text-base font-medium {{if eq .ActiveSection "profile"}}text-blue-300 bg-gray-700{{else}}text-gray-300 hover:text-white hover:bg-gray-700{{end}}">
|
|
||||||
<i class="fas fa-user-circle mr-3 w-4"></i>Profile
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Logout for Mobile -->
|
|
||||||
<div class="border-t border-gray-600 pt-2 mt-4">
|
|
||||||
<a href="/logout" class="block px-3 py-2 rounded-md text-base font-medium text-gray-300 hover:text-white hover:bg-gray-700">
|
|
||||||
<i class="fas fa-sign-out-alt mr-3 w-4"></i>Logout
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Page Content -->
|
||||||
<main class="flex-1 mt-14">
|
<div class="flex-1">
|
||||||
<!--sm:px-4 lg:px-6-->
|
|
||||||
<div class="max-w-9xl mx-auto overflow-hidden ">
|
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- Landing Page (unchanged) -->
|
<!-- Split Screen Login/Register Page -->
|
||||||
<!DOCTYPE html>
|
<div class="min-h-screen flex" x-data="{ isLogin: true }">
|
||||||
<html lang="en">
|
|
||||||
<head>
|
<!-- Left Side - Image -->
|
||||||
<meta charset="UTF-8">
|
<div class="hidden lg:flex flex-1 relative overflow-hidden">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<!-- Background overlay for better text readability -->
|
||||||
<title>Linq - Poll System</title>
|
<div class="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-blue-700/40 z-10"></div>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50">
|
|
||||||
<div class="min-h-screen" x-data="{ mobileMenuOpen: false }">
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="bg-gray-700 border-b border-gray-600">
|
|
||||||
<div class="max-w-7xl mx-auto px-6">
|
|
||||||
<div class="flex justify-between items-center h-14">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<img src="../../static/icon-512.png" alt="Logo" class="w-8 h-8"/>
|
|
||||||
<span class="text-xl font-semibold text-white">Linq</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop Navigation -->
|
|
||||||
<div class="hidden md:flex items-center gap-8">
|
|
||||||
<a href="#home" class="text-gray-300 hover:text-white py-3 px-1">Home</a>
|
|
||||||
<a href="#products" class="text-gray-300 hover:text-white py-3 px-1">Products</a>
|
|
||||||
<a href="#about" class="text-gray-300 hover:text-white py-3 px-1">About</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Auth Buttons -->
|
|
||||||
<div class="hidden md:flex items-center gap-3">
|
|
||||||
<button onclick="openLoginModal()" class="px-4 py-2 text-gray-300 hover:text-white">
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
<button onclick="openRegisterModal()" class="px-6 py-2 bg-blue-500 text-white hover:bg-blue-600 rounded">
|
|
||||||
Get Started
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Menu Button -->
|
|
||||||
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 text-gray-300 hover:text-white hover:bg-gray-600 rounded-md">
|
|
||||||
<i class="fas fa-bars" x-show="!mobileMenuOpen"></i>
|
|
||||||
<i class="fas fa-times" x-show="mobileMenuOpen"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- Background Image -->
|
||||||
<div x-show="mobileMenuOpen" class="md:hidden border-t border-gray-600 bg-gray-800">
|
<img src="../../static/feature-mobile1.jpg" alt="Welcome to Poll System" class="object-cover w-full h-full"/>
|
||||||
<div class="px-6 py-4 space-y-3">
|
|
||||||
<a href="#home" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">Home</a>
|
|
||||||
<a href="#products" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">Products</a>
|
|
||||||
<a href="#about" @click="mobileMenuOpen = false" class="block py-2 text-gray-300 hover:text-white">About</a>
|
|
||||||
<div class="border-t border-gray-600 pt-3 space-y-2">
|
|
||||||
<button onclick="openLoginModal(); document.querySelector('[x-data]').__x.$data.mobileMenuOpen = false"
|
|
||||||
class="block w-full text-left py-2 text-gray-300 hover:text-white">
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
<button onclick="openRegisterModal(); document.querySelector('[x-data]').__x.$data.mobileMenuOpen = false"
|
|
||||||
class="block w-full py-2 bg-blue-500 text-white hover:bg-blue-600 rounded">
|
|
||||||
Get Started
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Hero -->
|
|
||||||
<section id="home" class="pt-40 pb-16 px-6 min-h-[600px]">
|
|
||||||
<div class="max-w-4xl mx-auto text-center">
|
|
||||||
<h1 class="text-4xl sm:text-5xl font-bold text-gray-900 mb-6">
|
|
||||||
Simple Polling
|
|
||||||
<span class="text-blue-600">Management</span>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
<!-- Logo and branding overlay -->
|
||||||
Manage volunteers and track polling operations efficiently.
|
<div class="absolute top-8 left-8 z-20 flex items-center gap-3">
|
||||||
</p>
|
<div class="w-10 h-10 bg-white/20 backdrop-blur-sm rounded-full flex items-center justify-center">
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
|
||||||
<button onclick="openRegisterModal()" class="px-8 py-3 bg-blue-600 text-white hover:bg-blue-700 font-medium rounded">
|
|
||||||
Start Free
|
|
||||||
</button>
|
|
||||||
<button onclick="openLoginModal()" class="px-8 py-3 border border-gray-300 text-gray-700 hover:bg-gray-50 font-medium rounded">
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Products Section -->
|
|
||||||
<section id="products" class="py-16 bg-white min-h-[600px]">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
<div class="text-center mb-12">
|
|
||||||
<h2 class="text-3xl font-bold text-gray-900 mb-4">Our Products</h2>
|
|
||||||
<p class="text-lg text-gray-600">Tools built for modern polling operations</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-3 gap-8">
|
|
||||||
<div class="p-6 border border-gray-200 rounded-lg">
|
|
||||||
<img src="../../static/icon-512.png" alt="Poll Manager" class="w-12 h-12 mb-4"/>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Poll Manager</h3>
|
|
||||||
<p class="text-gray-600 mb-4">Complete polling campaign management with real-time tracking and coordination tools.</p>
|
|
||||||
<ul class="text-sm text-gray-500 space-y-1">
|
|
||||||
<li>• Volunteer coordination</li>
|
|
||||||
<li>• Address management</li>
|
|
||||||
<li>• Progress tracking</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6 border border-gray-200 rounded-lg">
|
|
||||||
<img src="../../static/icon-512.png" alt="Analytics Suite" class="w-12 h-12 mb-4"/>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Analytics Suite</h3>
|
|
||||||
<p class="text-gray-600 mb-4">Advanced reporting and analytics dashboard for data-driven insights.</p>
|
|
||||||
<ul class="text-sm text-gray-500 space-y-1">
|
|
||||||
<li>• Performance metrics</li>
|
|
||||||
<li>• Custom reports</li>
|
|
||||||
<li>• Data visualization</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-6 border border-gray-200 rounded-lg">
|
|
||||||
<img src="../../static/icon-512.png" alt="Team Builder" class="w-12 h-12 mb-4"/>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Team Builder</h3>
|
|
||||||
<p class="text-gray-600 mb-4">Organize teams and assign roles with automated scheduling and notifications.</p>
|
|
||||||
<ul class="text-sm text-gray-500 space-y-1">
|
|
||||||
<li>• Role assignment</li>
|
|
||||||
<li>• Schedule management</li>
|
|
||||||
<li>• Team communication</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- About -->
|
|
||||||
<section id="about" class="py-16 bg-gray-50 min-h-[600px]">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
<div class="grid lg:grid-cols-2 gap-12 items-center">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-3xl font-bold text-gray-900 mb-6">About Linq</h2>
|
|
||||||
<p class="text-lg text-gray-600 mb-6">
|
|
||||||
Built for organizations that need efficient polling management.
|
|
||||||
Our platform simplifies volunteer coordination and progress tracking.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-3 gap-6 mb-8">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-900">500+</div>
|
|
||||||
<div class="text-sm text-gray-500">Volunteers</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-900">50+</div>
|
|
||||||
<div class="text-sm text-gray-500">Organizations</div>
|
|
||||||
</div> -->
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-2xl font-bold text-gray-900">400,000+</div>
|
|
||||||
<div class="text-sm text-gray-500">Addresses</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white p-8 rounded-lg border border-gray-200">
|
|
||||||
<img src="../../static/feature-mobile4.jpg" alt="Dashboard Preview" class="w-full h-48 object-cover rounded mb-6 bg-gray-100"/>
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-3">Simple Dashboard</h3>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Everything you need in one place. Track progress, manage teams, and generate reports.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="bg-white border-t border-gray-200 py-8">
|
|
||||||
<div class="max-w-6xl mx-auto px-6">
|
|
||||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
||||||
<span class="font-semibold text-gray-900">Linq</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-2xl font-bold text-white">Linq</span>
|
||||||
<div class="flex items-center gap-6 text-sm text-gray-500">
|
</div>
|
||||||
<a href="#" class="hover:text-gray-900">Privacy</a>
|
|
||||||
<a href="#" class="hover:text-gray-900">Terms</a>
|
<!-- Welcome text overlay -->
|
||||||
<a href="#" class="hover:text-gray-900">Contact</a>
|
<div class="absolute bottom-8 left-8 right-8 z-20 text-white">
|
||||||
</div>
|
<h1 class="text-4xl font-bold mb-4">Welcome to Poll System</h1>
|
||||||
|
<p class="text-xl opacity-90">Streamline your polling operations with our comprehensive management platform</p>
|
||||||
<p class="text-sm text-gray-500">© 2025 Linq. All rights reserved.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
|
||||||
</div>
|
<!-- Right Side - Login/Register Forms -->
|
||||||
<!-- Login Modal -->
|
<div class="flex-1 flex items-center justify-center p-6 lg:p-12 bg-white">
|
||||||
<div id="loginModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 p-4">
|
<div class="w-full max-w-md">
|
||||||
<div class="bg-white shadow-2xl max-w-4xl w-full overflow-hidden rounded-lg">
|
|
||||||
<div class="flex flex-col lg:flex-row min-h-[500px]">
|
<!-- Mobile Logo (visible only on small screens) -->
|
||||||
<!-- Left Side - Image -->
|
<div class="lg:hidden flex items-center justify-center gap-3 mb-8">
|
||||||
<div class="hidden lg:flex flex-1 bg-gradient-to-br from-blue-500 to-blue-700 flex-col items-center justify-center">
|
<div class="w-10 h-10 bg-blue-primary rounded-full flex items-center justify-center">
|
||||||
<img src="../../static/feature-mobile2.jpg" alt="Welcome Image" class="object-cover h-full rounded-lg shadow-lg">
|
<img src="../../static/icon-512.png" alt="Logo" class="w-6 h-6"/>
|
||||||
</div>
|
|
||||||
<!-- Right Side - Form -->
|
|
||||||
<div class="flex-1 p-6 sm:p-8">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900">Sign In</h3>
|
|
||||||
<button onclick="closeLoginModal()" class="text-gray-400 hover:text-gray-600">
|
|
||||||
<i class="fas fa-times text-xl"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/login" class="space-y-6">
|
<span class="text-2xl font-bold text-text-primary">Linq</span>
|
||||||
<div>
|
</div>
|
||||||
<label for="login_email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
|
||||||
<input type="email" name="email" id="login_email" required
|
<!-- Toggle Buttons -->
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
<div class="flex justify-center gap-1 mb-8 p-1 bg-gray-100 rounded-lg">
|
||||||
|
<button @click="isLogin = true" :class="isLogin ? 'bg-white text-blue-primary shadow-sm' : 'text-gray-600'" class="flex-1 px-4 py-2 rounded-md font-medium transition-all duration-200">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<button @click="isLogin = false" :class="!isLogin ? 'bg-white text-blue-primary shadow-sm' : 'text-gray-600'" class="flex-1 px-4 py-2 rounded-md font-medium transition-all duration-200">
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<div x-show="isLogin" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform translate-x-4" x-transition:enter-end="opacity-100 transform translate-x-0">
|
||||||
|
<form action="/login" method="POST" class="space-y-6">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="text-3xl font-bold text-text-primary">Welcome back</h2>
|
||||||
|
<p class="text-text-secondary mt-2">Please sign in to your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="login_password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
<label for="login_email" class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||||
<input type="password" name="password" id="login_password" required
|
<input type="email"
|
||||||
class="w-full px-4 py-3 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
id="login_email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
placeholder="Enter your email"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors rounded">
|
|
||||||
|
<div>
|
||||||
|
<label for="login_password" class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||||
|
<input type="password"
|
||||||
|
id="login_password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
placeholder="Enter your password"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" class="h-4 w-4 text-blue-primary focus:ring-blue-primary border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm text-text-secondary">Remember me</span>
|
||||||
|
</label>
|
||||||
|
<a href="#" class="text-sm text-blue-primary hover:text-blue-600">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-center text-sm text-gray-600 mt-6">
|
|
||||||
Don't have an account?
|
|
||||||
<button onclick="switchToRegister()" class="text-blue-600 hover:text-blue-700 font-medium">Sign up</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register Modal -->
|
|
||||||
<div id="registerModal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50 p-4">
|
|
||||||
<div class="bg-white shadow-2xl max-w-4xl w-full overflow-hidden rounded-lg">
|
|
||||||
<div class="flex flex-col lg:flex-row min-h-[600px]">
|
|
||||||
<!-- Left Side - Image -->
|
|
||||||
<div class="hidden lg:flex flex-1 bg-gradient-to-br from-blue-600 to-blue-800 flex-col items-center justify-center">
|
|
||||||
<img src="../../static/feature-mobile1.jpg" alt="Welcome Image" class="object-cover h-full rounded-lg shadow-lg">
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Side - Form -->
|
<!-- Register Form -->
|
||||||
<div class="flex-1 p-6 sm:p-8 overflow-y-auto">
|
<div x-show="!isLogin" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 transform translate-x-4" x-transition:enter-end="opacity-100 transform translate-x-0">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<form action="/register" method="POST" class="space-y-6">
|
||||||
<h3 class="text-xl sm:text-2xl font-bold text-gray-900">Create Account</h3>
|
<div class="text-center mb-6">
|
||||||
<button onclick="closeRegisterModal()" class="text-gray-400 hover:text-gray-600">
|
<h2 class="text-3xl font-bold text-text-primary">Create Account</h2>
|
||||||
<i class="fas fa-times text-xl"></i>
|
<p class="text-text-secondary mt-2">Join our polling platform today</p>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
<form method="POST" action="/register" class="space-y-4">
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">First Name</label>
|
||||||
<input type="text" name="first_name" id="first_name" required
|
<input type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
name="first_name"
|
||||||
|
required
|
||||||
|
placeholder="First Name"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Last Name</label>
|
||||||
<input type="text" name="last_name" id="last_name" required
|
<input type="text"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
name="last_name"
|
||||||
|
required
|
||||||
|
placeholder="Last Name"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="register_email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Email Address</label>
|
||||||
<input type="email" name="email" id="register_email" required
|
<input type="email"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
name="email"
|
||||||
|
required
|
||||||
|
placeholder="Enter your email"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Phone</label>
|
||||||
<input type="tel" name="phone" id="phone"
|
<input type="tel"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
name="phone"
|
||||||
|
required
|
||||||
|
placeholder="Phone number"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="role" class="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Role</label>
|
||||||
<select name="role" id="role" required
|
<select name="role"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
required
|
||||||
onchange="toggleAdminCodeField()">
|
onchange="toggleAdminCodeField()"
|
||||||
<option value="">Select role</option>
|
id="role"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors">
|
||||||
|
<option value="">Select Role</option>
|
||||||
<option value="1">Admin</option>
|
<option value="1">Admin</option>
|
||||||
<option value="3">Volunteer</option>
|
<option value="3">Volunteer</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Admin Code field (hidden by default) -->
|
<!-- Admin/Team Leader Code Field (hidden by default) -->
|
||||||
<div id="adminCodeField" class="hidden">
|
<div id="adminCodeField" class="hidden">
|
||||||
<label for="admin_code" class="block text-sm font-medium text-gray-700 mb-1">Admin Code</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Access Code</label>
|
||||||
<input type="text" name="admin_code" id="admin_code"
|
<input type="password"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
name="admin_code"
|
||||||
placeholder="Enter your admin's code">
|
placeholder="Enter access code"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
|
<p class="text-xs text-text-secondary mt-1">Required for Admin and Team Leader roles</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="register_password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
<label class="block text-sm font-medium text-text-primary mb-2">Password</label>
|
||||||
<input type="password" name="password" id="register_password" required
|
<input type="password"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded focus:ring-blue-500 focus:border-blue-500 transition-colors">
|
name="password"
|
||||||
|
required
|
||||||
|
placeholder="Create a password"
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-primary focus:border-transparent transition-colors"/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="w-full bg-blue-600 text-white py-3 hover:bg-blue-700 font-medium transition-colors rounded mt-6">
|
|
||||||
|
<button type="submit" class="w-full bg-blue-primary text-white py-3 rounded-lg hover:bg-blue-600 focus:ring-2 focus:ring-blue-primary focus:ring-offset-2 transition-colors font-medium">
|
||||||
Create Account
|
Create Account
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-center text-sm text-gray-600 mt-4">
|
|
||||||
Already have an account?
|
|
||||||
<button onclick="switchToLogin()" class="text-blue-600 hover:text-blue-700 font-medium">Sign in</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Initialize Alpine.js data for mobile menu
|
// Sidebar functionality for authenticated users
|
||||||
document.addEventListener('alpine:init', () => {
|
function toggleSidebar() {
|
||||||
Alpine.data('sidebar', () => ({
|
const sidebar = document.getElementById('sidebar');
|
||||||
open: false
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Smooth scrolling for navigation links
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const links = document.querySelectorAll('a[href^="#"]');
|
|
||||||
|
|
||||||
for (const link of links) {
|
if (sidebar && sidebarOverlay) {
|
||||||
link.addEventListener('click', function(e) {
|
sidebar.classList.toggle('active');
|
||||||
e.preventDefault();
|
sidebarOverlay.classList.toggle('active');
|
||||||
const targetId = this.getAttribute('href').substring(1);
|
|
||||||
const targetElement = document.getElementById(targetId);
|
|
||||||
|
|
||||||
if (targetElement) {
|
|
||||||
const offsetTop = targetElement.offsetTop - 80; // Account for fixed navbar
|
|
||||||
window.scrollTo({
|
|
||||||
top: offsetTop,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function openLoginModal() {
|
|
||||||
document.getElementById('loginModal').classList.remove('hidden');
|
|
||||||
document.getElementById('loginModal').classList.add('flex');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLoginModal() {
|
|
||||||
document.getElementById('loginModal').classList.add('hidden');
|
|
||||||
document.getElementById('loginModal').classList.remove('flex');
|
|
||||||
document.body.style.overflow = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function openRegisterModal() {
|
|
||||||
document.getElementById('registerModal').classList.remove('hidden');
|
|
||||||
document.getElementById('registerModal').classList.add('flex');
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRegisterModal() {
|
|
||||||
document.getElementById('registerModal').classList.add('hidden');
|
|
||||||
document.getElementById('registerModal').classList.remove('flex');
|
|
||||||
document.body.style.overflow = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToRegister() {
|
|
||||||
closeLoginModal();
|
|
||||||
setTimeout(() => openRegisterModal(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToLogin() {
|
|
||||||
closeRegisterModal();
|
|
||||||
setTimeout(() => openLoginModal(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const loginModal = document.getElementById('loginModal');
|
|
||||||
const registerModal = document.getElementById('registerModal');
|
|
||||||
|
|
||||||
if (event.target === loginModal) {
|
|
||||||
closeLoginModal();
|
|
||||||
}
|
|
||||||
if (event.target === registerModal) {
|
|
||||||
closeRegisterModal();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile menu toggle
|
||||||
|
function toggleProfileMenu() {
|
||||||
|
const menu = document.getElementById('profile-menu');
|
||||||
|
if (menu) {
|
||||||
|
menu.classList.toggle('opacity-0');
|
||||||
|
menu.classList.toggle('invisible');
|
||||||
|
menu.classList.toggle('opacity-100');
|
||||||
|
menu.classList.toggle('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin code field toggle for registration
|
||||||
function toggleAdminCodeField() {
|
function toggleAdminCodeField() {
|
||||||
const role = document.getElementById("role").value;
|
const role = document.getElementById("role");
|
||||||
const field = document.getElementById("adminCodeField");
|
const field = document.getElementById("adminCodeField");
|
||||||
field.classList.toggle("hidden", role !== "3" && role !== "2"); // show only if Volunteer or Team Leader
|
|
||||||
|
if (role && field) {
|
||||||
|
const roleValue = role.value;
|
||||||
|
if (roleValue === "1") { // Admin or Team Leader
|
||||||
|
field.classList.add("hidden");
|
||||||
|
} else {
|
||||||
|
field.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle escape key
|
// Close sidebar on ESC key
|
||||||
document.addEventListener('keydown', function(event) {
|
document.addEventListener('keydown', (e) => {
|
||||||
if (event.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeLoginModal();
|
const sidebar = document.getElementById('sidebar');
|
||||||
closeRegisterModal();
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
}
|
const body = document.body;
|
||||||
});
|
|
||||||
|
if (sidebar && sidebarOverlay && sidebar.classList.contains('active')) {
|
||||||
// Close mobile menu when clicking outside (for landing page)
|
sidebar.classList.remove('active');
|
||||||
document.addEventListener('click', function(event) {
|
sidebarOverlay.classList.remove('active');
|
||||||
const mobileMenuButton = event.target.closest('[\\@click="mobileMenuOpen = !mobileMenuOpen"]');
|
body.style.overflow = '';
|
||||||
const mobileMenu = event.target.closest('.md\\:hidden .bg-white');
|
}
|
||||||
|
|
||||||
if (!mobileMenuButton && !mobileMenu) {
|
|
||||||
// This will be handled by Alpine.js automatically
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle window resize to ensure proper mobile behavior
|
// Handle window resize to ensure proper mobile behavior
|
||||||
window.addEventListener('resize', function() {
|
window.addEventListener('resize', function() {
|
||||||
if (window.innerWidth >= 1024) {
|
if (window.innerWidth >= 768) {
|
||||||
// Close mobile menus on desktop
|
const sidebar = document.getElementById('sidebar');
|
||||||
const sidebarComponent = document.querySelector('[x-data]');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
if (sidebarComponent && sidebarComponent.__x) {
|
const body = document.body;
|
||||||
sidebarComponent.__x.$data.sidebarOpen = false;
|
|
||||||
sidebarComponent.__x.$data.mobileMenuOpen = false;
|
if (sidebar && sidebarOverlay) {
|
||||||
|
sidebar.classList.remove('active');
|
||||||
|
sidebarOverlay.classList.remove('active');
|
||||||
|
body.style.overflow = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Close sidebar when clicking on overlay
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
|
if (sidebarOverlay) {
|
||||||
|
sidebarOverlay.addEventListener('click', function() {
|
||||||
|
toggleSidebar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="min-h-screen bg-gray-100">
|
<div class="min-h-screen bg-gray-100">
|
||||||
<!-- Header -->
|
|
||||||
|
|
||||||
<div class="max-w-2xl mx-auto">
|
<div class="max-w-2xl mx-auto">
|
||||||
<!-- Create Post Form -->
|
<!-- Create Post Form -->
|
||||||
|
|||||||
@@ -16,10 +16,9 @@
|
|||||||
>
|
>
|
||||||
<option value="">Select Category</option>
|
<option value="">Select Category</option>
|
||||||
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
|
<option value="users" {{if eq .Category "users"}}selected{{end}}>Users & Teams</option>
|
||||||
<option value="addresses" {{if eq .Category "addresses"}}selected{{end}}>Addresses</option>
|
<option value="address" {{if eq .Category "address"}}selected{{end}}>Addresses</option>
|
||||||
<option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
|
<option value="appointments" {{if eq .Category "appointments"}}selected{{end}}>Appointments</option>
|
||||||
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
<option value="polls" {{if eq .Category "polls"}}selected{{end}}>Polls</option>
|
||||||
<option value="responses" {{if eq .Category "responses"}}selected{{end}}>Poll Responses</option>
|
|
||||||
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
<option value="availability" {{if eq .Category "availability"}}selected{{end}}>Availability</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,30 +40,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date Range (optional) -->
|
<!-- Date Range -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label for="date_from" class="text-gray-700 font-medium">From:</label>
|
<label for="date_from" class="text-gray-700 font-medium">From:</label>
|
||||||
<input
|
<input type="date" name="date_from" id="date_from" value="{{.DateFrom}}" class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"/>
|
||||||
type="date"
|
|
||||||
name="date_from"
|
|
||||||
id="date_from"
|
|
||||||
value="{{.DateFrom}}"
|
|
||||||
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
|
|
||||||
/>
|
|
||||||
<label for="date_to" class="text-gray-700 font-medium">To:</label>
|
<label for="date_to" class="text-gray-700 font-medium">To:</label>
|
||||||
<input
|
<input type="date" name="date_to" id="date_to" value="{{.DateTo}}" class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"/>
|
||||||
type="date"
|
|
||||||
name="date_to"
|
|
||||||
id="date_to"
|
|
||||||
value="{{.DateTo}}"
|
|
||||||
class="px-3 py-2 text-sm border border-gray-200 bg-white focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-purple-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit" class="px-4 py-2 bg-purple-600 text-white font-medium hover:bg-purple-700 transition-all duration-200 text-sm">
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-purple-600 text-white font-medium hover:bg-purple-700 transition-all duration-200 text-sm"
|
|
||||||
>
|
|
||||||
<i class="fas fa-chart-bar mr-2"></i>Generate Report
|
<i class="fas fa-chart-bar mr-2"></i>Generate Report
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -76,16 +60,10 @@
|
|||||||
<div class="text-gray-600">
|
<div class="text-gray-600">
|
||||||
<span>{{.Result.Count}} results</span>
|
<span>{{.Result.Count}} results</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onclick="exportResults()" class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors">
|
||||||
onclick="exportResults()"
|
|
||||||
class="px-3 py-1.5 bg-green-600 text-white hover:bg-green-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fas fa-download mr-1"></i>Export CSV
|
<i class="fas fa-download mr-1"></i>Export CSV
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onclick="printReport()" class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||||
onclick="printReport()"
|
|
||||||
class="px-3 py-1.5 bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<i class="fas fa-print mr-1"></i>Print
|
<i class="fas fa-print mr-1"></i>Print
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,43 +71,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto">
|
||||||
{{if .Result}}
|
{{if .Result}}
|
||||||
{{if .Result.Error}}
|
{{if .Result.Error}}
|
||||||
<!-- Error State -->
|
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="bg-red-50 border border-red-200 p-6">
|
<div class="bg-red-50 border border-red-200 p-6">
|
||||||
<div class="flex items-start">
|
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
||||||
<div class="w-10 h-10 bg-red-100 flex items-center justify-center flex-shrink-0">
|
<p class="text-red-700">{{.Result.Error}}</p>
|
||||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<h3 class="text-lg font-semibold text-red-800 mb-2">Report Error</h3>
|
|
||||||
<p class="text-red-700">{{.Result.Error}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- Report Header -->
|
<!-- Report Header -->
|
||||||
<div class="bg-white border-b border-gray-200 px-6 py-4">
|
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
||||||
<h2 class="text-xl font-semibold text-gray-900">{{.ReportTitle}}</h2>
|
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
||||||
<p class="text-sm text-gray-600 mt-1">{{.ReportDescription}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
Generated: {{.GeneratedAt}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">Generated: {{.GeneratedAt}}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results Table -->
|
<!-- Results Table -->
|
||||||
{{if gt .Result.Count 0}}
|
{{if gt .Result.Count 0}}
|
||||||
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
|
<div class="flex-1 overflow-x-auto overflow-y-auto bg-white">
|
||||||
<table class="w-full divide-gray-200 text-sm table-auto">
|
<table class="w-full divide-gray-200 text-sm table-auto">
|
||||||
<thead class="bg-gray-50 divide-gray-200 sticky top-0">
|
<thead class="bg-gray-50 sticky top-0">
|
||||||
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
|
<tr class="text-left text-gray-700 font-medium border-b border-gray-200">
|
||||||
{{range .Result.Columns}}
|
{{range .Result.Columns}}
|
||||||
<th class="px-6 py-3 whitespace-nowrap">{{formatColumnName .}}</th>
|
<th class="px-6 py-3 whitespace-nowrap">{{formatColumnName .}}</th>
|
||||||
@@ -148,7 +114,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats (if available) -->
|
<!-- Summary Stats -->
|
||||||
{{if .SummaryStats}}
|
{{if .SummaryStats}}
|
||||||
<div class="bg-gray-50 border-t border-gray-200 px-6 py-4">
|
<div class="bg-gray-50 border-t border-gray-200 px-6 py-4">
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">Summary Statistics</h4>
|
<h4 class="text-sm font-semibold text-gray-700 mb-3">Summary Statistics</h4>
|
||||||
@@ -164,187 +130,75 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- No Results State -->
|
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<div class="flex-1 flex items-center justify-center">
|
||||||
<div class="text-center py-12">
|
<p class="text-gray-500">No results match your selected criteria</p>
|
||||||
<div class="w-16 h-16 bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<i class="fas fa-chart-bar text-gray-400 text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No Data Found</h3>
|
|
||||||
<p class="text-gray-500">No results match your selected criteria</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<!-- Welcome State -->
|
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<div class="flex-1 flex items-center justify-center">
|
||||||
<div class="text-center py-12 max-w-4xl mx-auto px-6">
|
<p class="text-gray-600">Select a category and report to generate results</p>
|
||||||
<div class="mb-8">
|
|
||||||
<div class="w-20 h-20 bg-gradient-to-br from-purple-600 to-purple-700 flex items-center justify-center mx-auto mb-4">
|
|
||||||
<i class="fas fa-chart-line text-white text-2xl"></i>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Campaign Reports</h1>
|
|
||||||
<p class="text-gray-600 text-lg">Generate detailed reports across all your campaign data</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Report Categories Overview -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-left">
|
|
||||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
|
||||||
<div class="font-medium text-gray-900 text-sm mb-1">Users & Teams</div>
|
|
||||||
<div class="text-xs text-gray-500">Volunteer performance, team stats, role distribution</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
|
||||||
<div class="font-medium text-gray-900 text-sm mb-1">Address Reports</div>
|
|
||||||
<div class="text-xs text-gray-500">Coverage areas, visit status, geographical insights</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
|
||||||
<div class="font-medium text-gray-900 text-sm mb-1">Appointments</div>
|
|
||||||
<div class="text-xs text-gray-500">Schedule analysis, completion rates, time trends</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-4 bg-white border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all duration-200">
|
|
||||||
<div class="font-medium text-gray-900 text-sm mb-1">Poll Analytics</div>
|
|
||||||
<div class="text-xs text-gray-500">Response rates, donation tracking, engagement metrics</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 text-sm text-gray-500">
|
|
||||||
Select a category above to see available reports
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Square corners across UI */
|
|
||||||
* {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, button {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.no-print {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Print-specific styles */
|
|
||||||
body {
|
|
||||||
background: white !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-50 {
|
|
||||||
background: white !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Report definitions for each category
|
|
||||||
const reportDefinitions = {
|
const reportDefinitions = {
|
||||||
users: [
|
users: [
|
||||||
{ id: 'users_by_role', name: 'Users by Role' },
|
{ id: 'participation', name: 'Volunteer Participation Rate' },
|
||||||
{ id: 'volunteer_activity', name: 'Volunteer Activity Summary' },
|
{ id: 'top_performers', name: 'Top Performing Volunteers' },
|
||||||
{ id: 'team_performance', name: 'Team Performance Report' },
|
{ id: 'efficiency', name: 'Response-to-Donation Ratio' },
|
||||||
{ id: 'admin_workload', name: 'Admin Workload Analysis' },
|
{ id: 'coverage', name: 'User Address Coverage' }
|
||||||
{ id: 'inactive_users', name: 'Inactive Users Report' }
|
|
||||||
],
|
],
|
||||||
addresses: [
|
address: [
|
||||||
{ id: 'coverage_by_area', name: 'Coverage by Area' },
|
{ id: 'responses_by_address', name: 'Total Responses by Address' },
|
||||||
{ id: 'visits_by_postal', name: 'Visits by Postal Code' },
|
{ id: 'donations_by_address', name: 'Total Donations by Address' },
|
||||||
{ id: 'unvisited_addresses', name: 'Unvisited Addresses' },
|
{ id: 'street_breakdown', name: 'Street-Level Breakdown' },
|
||||||
{ id: 'donations_by_location', name: 'Donations by Location' },
|
{ id: 'quadrant_summary', name: 'Quadrant Summary' }
|
||||||
{ id: 'address_validation_status', name: 'Address Validation Status' }
|
|
||||||
],
|
],
|
||||||
appointments: [
|
appointments: [
|
||||||
{ id: 'appointments_by_day', name: 'Appointments by Day' },
|
{ id: 'upcoming', name: 'Upcoming Appointments' },
|
||||||
{ id: 'completion_rates', name: 'Completion Rates' },
|
{ id: 'completion', name: 'Appointments Completion Rate' },
|
||||||
{ id: 'volunteer_schedules', name: 'Volunteer Schedules' },
|
{ id: 'geo_distribution', name: 'Appointments by Quadrant' },
|
||||||
{ id: 'missed_appointments', name: 'Missed Appointments' },
|
{ id: 'lead_time', name: 'Average Lead Time' }
|
||||||
{ id: 'peak_hours', name: 'Peak Activity Hours' }
|
|
||||||
],
|
],
|
||||||
polls: [
|
polls: [
|
||||||
{ id: 'poll_creation_stats', name: 'Poll Creation Statistics' },
|
{ id: 'distribution', name: 'Response Distribution' },
|
||||||
{ id: 'donation_analysis', name: 'Donation Analysis' },
|
{ id: 'average', name: 'Average Poll Response' },
|
||||||
{ id: 'active_vs_inactive', name: 'Active vs Inactive Polls' },
|
{ id: 'donations_by_poll', name: 'Donations by Poll' },
|
||||||
{ id: 'poll_trends', name: 'Poll Activity Trends' },
|
{ id: 'correlation', name: 'Response-to-Donation Correlation' }
|
||||||
{ id: 'creator_performance', name: 'Creator Performance' }
|
|
||||||
],
|
|
||||||
responses: [
|
|
||||||
{ id: 'voter_status', name: 'Voter Status Report' },
|
|
||||||
{ id: 'sign_requests', name: 'Sign Requests Summary' },
|
|
||||||
{ id: 'feedback_analysis', name: 'Feedback Analysis' },
|
|
||||||
{ id: 'response_trends', name: 'Response Trends' },
|
|
||||||
{ id: 'repeat_voters', name: 'Repeat Voters Analysis' }
|
|
||||||
],
|
],
|
||||||
availability: [
|
availability: [
|
||||||
{ id: 'volunteer_availability', name: 'Volunteer Availability' },
|
{ id: 'by_date', name: 'Volunteer Availability by Date' },
|
||||||
{ id: 'peak_availability', name: 'Peak Availability Times' },
|
{ id: 'gaps', name: 'Coverage Gaps' },
|
||||||
{ id: 'coverage_gaps', name: 'Coverage Gaps' },
|
{ id: 'overlaps', name: 'Volunteer Overlaps' },
|
||||||
{ id: 'schedule_conflicts', name: 'Schedule Conflicts' }
|
{ id: 'fulfillment', name: 'Volunteer Fulfillment' }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update reports dropdown when category changes
|
|
||||||
function updateReports() {
|
function updateReports() {
|
||||||
const categorySelect = document.getElementById('category');
|
const category = document.getElementById('category').value;
|
||||||
const reportSelect = document.getElementById('report');
|
const reportSelect = document.getElementById('report');
|
||||||
const category = categorySelect.value;
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
reportSelect.innerHTML = '<option value="">Select Report</option>';
|
reportSelect.innerHTML = '<option value="">Select Report</option>';
|
||||||
|
if (reportDefinitions[category]) {
|
||||||
if (category && reportDefinitions[category]) {
|
reportDefinitions[category].forEach(r => {
|
||||||
reportDefinitions[category].forEach(report => {
|
const opt = document.createElement('option');
|
||||||
const option = document.createElement('option');
|
opt.value = r.id;
|
||||||
option.value = report.id;
|
opt.textContent = r.name;
|
||||||
option.textContent = report.name;
|
reportSelect.appendChild(opt);
|
||||||
reportSelect.appendChild(option);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export results
|
|
||||||
function exportResults() {
|
function exportResults() {
|
||||||
const form = document.querySelector('form');
|
const params = new URLSearchParams(new FormData(document.querySelector('form')));
|
||||||
const formData = new FormData(form);
|
|
||||||
const params = new URLSearchParams(formData);
|
|
||||||
params.set('export', 'csv');
|
params.set('export', 'csv');
|
||||||
window.location.href = `/reports/export?${params.toString()}`;
|
window.location.href = `/reports?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print report
|
function printReport() { window.print(); }
|
||||||
function printReport() {
|
|
||||||
window.print();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize on page load
|
document.addEventListener("DOMContentLoaded", updateReports);
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
updateReports();
|
|
||||||
|
|
||||||
// Set default date range (last 30 days)
|
|
||||||
const dateFrom = document.getElementById('date_from');
|
|
||||||
const dateTo = document.getElementById('date_to');
|
|
||||||
|
|
||||||
if (!dateFrom.value) {
|
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
dateFrom.value = thirtyDaysAgo.toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dateTo.value) {
|
|
||||||
dateTo.value = new Date().toISOString().split('T')[0];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
|
||||||
|
|||||||
208
app/internal/templates/team_builder.html
Normal file
208
app/internal/templates/team_builder.html
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
{{ define "content" }}
|
||||||
|
<div class="flex-1 flex flex-col overflow-hidden ">
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 p-4 md:p-6 overflow-auto">
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{ range .TeamLeads }}
|
||||||
|
{{ $teamLeadID := .ID }}
|
||||||
|
<!-- Team Lead Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<!-- Team Lead Header -->
|
||||||
|
<div class="bg-gray-50 border-b border-gray-200 px-6 py-4">
|
||||||
|
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4">
|
||||||
|
<!-- Team Lead Info -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-user-tie text-white"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ .Name }}</h3>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
{{ if .Volunteers }}
|
||||||
|
{{ len .Volunteers }} volunteer{{ if ne (len .Volunteers) 1 }}s{{ end }} assigned
|
||||||
|
{{ else }}
|
||||||
|
No volunteers assigned
|
||||||
|
{{ end }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Volunteer Form -->
|
||||||
|
{{ if $.UnassignedVolunteers }}
|
||||||
|
<form action="/team_builder" method="POST" class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 w-full lg:w-auto">
|
||||||
|
<input type="hidden" name="team_lead_id" value="{{ .ID }}" />
|
||||||
|
<select
|
||||||
|
name="volunteer_id"
|
||||||
|
class="px-4 py-2 text-sm border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 min-w-0 flex-1 sm:w-64"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select volunteer to assign</option>
|
||||||
|
{{ range $.UnassignedVolunteers }}
|
||||||
|
<option value="{{ .ID }}">{{ .Name }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex items-center justify-center px-4 py-2 bg-blue-500 text-white text-sm hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<i class="fas fa-plus mr-2"></i> Add Volunteer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-sm text-gray-500 bg-gray-100 px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
All volunteers have been assigned
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Volunteer List -->
|
||||||
|
<div class="hidden md:block">
|
||||||
|
{{ if .Volunteers }}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Volunteer
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-100">
|
||||||
|
{{ range .Volunteers }}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-user text-gray-500 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ .Name }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
<i class="fas fa-check mr-1"></i> Assigned
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<form action="/team_builder/remove_volunteer" method="POST" class="inline-block">
|
||||||
|
<input type="hidden" name="team_lead_id" value="{{ $teamLeadID }}" />
|
||||||
|
<input type="hidden" name="volunteer_id" value="{{ .ID }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="text-red-400 hover:text-red-600 p-1"
|
||||||
|
title="Remove {{ .Name }} from team"
|
||||||
|
onclick="return confirm('Remove {{ .Name }} from this team?')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="px-6 py-8 text-center">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-users text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No volunteers assigned</h3>
|
||||||
|
<p class="text-gray-500">Use the form above to assign volunteers to this team lead.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Volunteer Cards -->
|
||||||
|
<div class="md:hidden">
|
||||||
|
{{ if .Volunteers }}
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{ range .Volunteers }}
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0">
|
||||||
|
<i class="fas fa-user text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-900 truncate">{{ .Name }}</div>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<span class="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
<i class="fas fa-check mr-1"></i> Assigned
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form action="/team_builder/remove_volunteer" method="POST" class="ml-4">
|
||||||
|
<input type="hidden" name="team_lead_id" value="{{ $teamLeadID }}" />
|
||||||
|
<input type="hidden" name="volunteer_id" value="{{ .ID }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors text-sm font-medium"
|
||||||
|
onclick="return confirm('Remove {{ .Name }} from this team?')"
|
||||||
|
>
|
||||||
|
<i class="fas fa-trash mr-1"></i> Remove
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="px-6 py-8 text-center">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-users text-4xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">No volunteers assigned</h3>
|
||||||
|
<p class="text-gray-500 text-sm">Use the form above to assign volunteers to this team lead.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<!-- No Team Leads State -->
|
||||||
|
{{ if not .TeamLeads }}
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-6 py-12 text-center">
|
||||||
|
<div class="text-gray-400 mb-4">
|
||||||
|
<i class="fas fa-user-tie text-6xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-medium text-gray-900 mb-2">No Team Leads Available</h3>
|
||||||
|
<p class="text-gray-500">Add team leads to start building teams and assigning volunteers.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Consistent styling with addresses component */
|
||||||
|
input, select, button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper responsive behavior */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
--tw-space-x-reverse: 0;
|
||||||
|
margin-right: calc(1rem * var(--tw-space-x-reverse));
|
||||||
|
margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
{{ define "content" }}
|
|
||||||
<div class="min-h-screen bg-gray-50">
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
{{range .TeamLeads}} {{ $teamLeadID := .ID }}
|
|
||||||
<!-- store team lead ID -->
|
|
||||||
|
|
||||||
<div class="bg-white border border-gray-200 shadow-sm">
|
|
||||||
<!-- Team Lead Header -->
|
|
||||||
<div
|
|
||||||
class="flex justify-between items-center px-4 py-3 border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<i class="fas fa-user-tie text-blue-600"></i>
|
|
||||||
<span class="font-semibold text-gray-900">{{.Name}}</span>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
action="/team_builder"
|
|
||||||
method="POST"
|
|
||||||
class="flex items-center space-x-3"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="team_lead_id" value="{{.ID}}" />
|
|
||||||
|
|
||||||
<select
|
|
||||||
name="volunteer_id"
|
|
||||||
class="px-3 py-2 border border-gray-300 bg-white text-gray-700 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="">--Select Volunteer--</option>
|
|
||||||
{{range $.UnassignedVolunteers}}
|
|
||||||
<option value="{{.ID}}">{{.Name}}</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 font-medium"
|
|
||||||
>
|
|
||||||
<i class="fas fa-plus mr-2"></i> Add
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assigned Volunteers -->
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
{{if .Volunteers}}
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{{range .Volunteers}}
|
|
||||||
<li
|
|
||||||
class="flex items-center justify-between text-gray-800 border-b border-gray-200 py-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<i class="fas fa-user text-gray-500"></i>
|
|
||||||
<span>{{.Name}}</span>
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
action="/team_builder/remove_volunteer"
|
|
||||||
method="POST"
|
|
||||||
class="flex-shrink-0"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="team_lead_id"
|
|
||||||
value="{{ $teamLeadID }}"
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="volunteer_id" value="{{.ID}}" />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
aria-label="Remove {{.Name}}"
|
|
||||||
class="px-3 py-1 bg-red-600 text-white hover:bg-red-700 focus:outline-none focus:ring-1 focus:ring-red-500"
|
|
||||||
>
|
|
||||||
<i class="fas fa-times"></i> Remove
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
{{else}}
|
|
||||||
<p class="text-gray-500 italic">No volunteers assigned yet.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Square corners across UI */
|
|
||||||
* {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.025em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{ end }}
|
|
||||||
@@ -110,7 +110,7 @@ func schedualHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
func HomeHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
utils.Render(w, "dashboard/dashboard.html", map[string]interface{}{
|
utils.Render(w, "dashboard.html", map[string]interface{}{
|
||||||
"Title": "Admin Dashboard",
|
"Title": "Admin Dashboard",
|
||||||
"IsAuthenticated": false,
|
"IsAuthenticated": false,
|
||||||
"ActiveSection": "dashboard",
|
"ActiveSection": "dashboard",
|
||||||
@@ -167,6 +167,6 @@ func main() {
|
|||||||
// Poll routes (volunteer only)
|
// Poll routes (volunteer only)
|
||||||
http.HandleFunc("/poll", volunteerMiddleware(handlers.PollHandler))
|
http.HandleFunc("/poll", volunteerMiddleware(handlers.PollHandler))
|
||||||
|
|
||||||
log.Println("Server started on localhost:8080")
|
log.Println("Server started on http://localhost:8080")
|
||||||
log.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
|
log.Fatal(http.ListenAndServe("0.0.0.0:8080", nil))
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user