Resolve conflicts: remove Woodpecker CI, use Gitea
Some checks failed
build-and-push / build (push) Failing after 10s

This commit is contained in:
Tracewebstudio Dev 2026-05-08 15:41:06 +02:00
commit ee45ea44db
47 changed files with 13421 additions and 6743 deletions

View file

@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Registry Image Tag Pruner - Keeps only the latest 1 SHA-tag per repository.
Usage:
python3 registry_prune.py \
--registry registry.nxtgauge.com \
--repo nxtgauge-rust-gateway \
--username "$REGISTRY_USERNAME" \
--password "$REGISTRY_PASSWORD"
Environment variables can also be used:
REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD
SHA-like tags are identified by pattern: ^[a-f0-9]{40}$
Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are NEVER deleted.
Exit code: 0 on success (or if prune fails gracefully), non-zero only on critical error.
"""
import argparse
import base64
import json
import os
import sys
import time
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
def parse_args():
parser = argparse.ArgumentParser(
description="Prune Docker registry tags, keeping only the latest SHA tag."
)
parser.add_argument("--registry", default=os.environ.get("REGISTRY_HOST"))
parser.add_argument("--repo", default=os.environ.get("REGISTRY_REPO"))
parser.add_argument("--username", default=os.environ.get("REGISTRY_USERNAME"))
parser.add_argument("--password", default=os.environ.get("REGISTRY_PASSWORD"))
parser.add_argument("--keep", type=int, default=1, help="Number of SHA tags to keep (default: 1)")
return parser.parse_args()
def api_request(url: str, method: str, username: str, password: str, data=None, retries: int = 3) -> dict | None:
"""Make an authenticated API request with retry logic."""
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
headers = {
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
}
for attempt in range(1, retries + 1):
try:
req = Request(url, method=method, headers=headers, data=data)
with urlopen(req, timeout=30) as response:
content = response.read()
if content:
return json.loads(content)
return {}
except HTTPError as e:
if e.code == 401:
print(f" [ERROR] Authentication failed (401)")
return None
if e.code == 404:
print(f" [WARN] Resource not found: {url}")
return None
print(f" [RETRY {attempt}/{retries}] HTTP {e.code} for {url}")
except URLError as e:
print(f" [RETRY {attempt}/{retries}] URL error: {e.reason}")
except Exception as e:
print(f" [RETRY {attempt}/{retries}] Error: {e}")
if attempt < retries:
time.sleep(attempt * 2)
print(f" [ERROR] Failed after {retries} attempts for {url}")
return None
def get_tag_digest(registry: str, repo: str, tag: str, username: str, password: str) -> tuple[str, str] | None:
"""Get the digest (sha256:...) and created time for a tag."""
url = f"https://{registry}/v2/{repo}/manifests/{tag}"
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
for attempt in range(1, 4):
try:
req = Request(url, method="GET", headers={
"Authorization": f"Basic {auth}",
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
})
with urlopen(req, timeout=30) as response:
digest = response.headers.get("Docker-Content-Digest", "")
created = response.headers.get("Date", "")
return digest, created
except Exception as e:
print(f" [RETRY {attempt}/3] Getting digest for {tag}: {e}")
time.sleep(attempt)
return None
def delete_tag(registry: str, repo: str, digest: str, username: str, password: str) -> bool:
"""Delete a tag by its digest."""
url = f"https://{registry}/v2/{repo}/manifests/{digest}"
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
for attempt in range(1, 4):
try:
req = Request(url, method="DELETE", headers={
"Authorization": f"Basic {auth}",
})
with urlopen(req, timeout=30) as response:
if response.status in (200, 202, 404):
return True
except HTTPError as e:
if e.code == 404:
return True # Already deleted
print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
except Exception as e:
print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
time.sleep(attempt)
return False
def is_sha_tag(tag: str) -> bool:
"""Check if tag looks like a SHA (40 hex chars)."""
import re
return bool(re.match(r"^[a-f0-9]{40}$", tag))
def prune_tags(registry: str, repo: str, username: str, password: str, keep: int = 1) -> bool:
"""
Main prune logic:
- List all tags for the repo
- Filter SHA-like tags
- Sort by created date (newest first)
- Keep newest `keep` tags
- Delete older SHA tags by digest
- Never delete non-SHA tags
"""
print(f"\n=== Pruning {registry}/{repo} ===")
print(f"Strategy: Keep {keep} newest SHA tag(s), delete older SHA tags")
print(f"Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are preserved\n")
# Get catalog (list of repos)
catalog_url = f"https://{registry}/v2/_catalog"
catalog = api_request(catalog_url, "GET", username, password)
if catalog is None:
print("[ERROR] Failed to get repository catalog")
return False
if repo not in catalog.get("repositories", []):
print(f"[INFO] Repository {repo} not found in catalog")
return True
# Get tags for repo
tags_url = f"https://{registry}/v2/{repo}/tags/list"
tags_data = api_request(tags_url, "GET", username, password)
if tags_data is None:
print(f"[ERROR] Failed to get tags for {repo}")
return False
all_tags = tags_data.get("tags", [])
if not all_tags:
print("[INFO] No tags found")
return True
# Separate SHA tags from non-SHA tags
sha_tags = [t for t in all_tags if is_sha_tag(t)]
non_sha_tags = [t for t in all_tags if not is_sha_tag(t)]
print(f"Total tags: {len(all_tags)}")
print(f" SHA tags (candidates for pruning): {len(sha_tags)}")
print(f" Non-SHA tags (protected): {len(non_sha_tags)}")
if non_sha_tags:
print(f" Protected tags: {', '.join(sorted(non_sha_tags))}")
if not sha_tags:
print("\n[INFO] No SHA tags to prune")
return True
# Get digest and created time for each SHA tag
tag_info = []
for tag in sha_tags:
result = get_tag_digest(registry, repo, tag, username, password)
if result:
digest, created = result
tag_info.append({
"tag": tag,
"digest": digest,
"created": created,
"timestamp": parse_http_date(created) if created else 0,
})
time.sleep(0.1) # Be nice to the registry
if not tag_info:
print("\n[ERROR] Could not get info for any SHA tags")
return False
# Sort by timestamp (newest first)
tag_info.sort(key=lambda x: x["timestamp"], reverse=True)
print(f"\nSHA tags sorted by age (newest first):")
for i, info in enumerate(tag_info):
marker = " [KEEP]" if i < keep else " [DELETE]"
print(f" {i+1}. {info['tag']} ({info['created'] or 'unknown date'}){marker}")
# Delete older SHA tags
deleted_count = 0
kept_count = 0
for i, info in enumerate(tag_info):
if i < keep:
print(f"\n[KEEP] {info['tag']}")
kept_count += 1
continue
print(f"\n[DELETE] {info['tag']} (digest: {info['digest'][:20]}...)")
if delete_tag(registry, repo, info["digest"], username, password):
print(f" [OK] Deleted {info['tag']}")
deleted_count += 1
else:
print(f" [WARN] Failed to delete {info['tag']} (will retry next run)")
time.sleep(0.2) # Be nice to the registry
print(f"\n=== Prune Summary ===")
print(f"Tags kept: {kept_count}")
print(f"Tags deleted: {deleted_count}")
print(f"Tags protected (non-SHA): {len(non_sha_tags)}")
return True
def parse_http_date(date_str: str) -> float:
"""Parse HTTP Date header to timestamp."""
from email.utils import parsedate_to_datetime
try:
return parsedate_to_datetime(date_str).timestamp()
except Exception:
return 0
def main():
args = parse_args()
# Validate required args
registry = args.registry or os.environ.get("REGISTRY_HOST")
repo = args.repo or os.environ.get("REGISTRY_REPO")
username = args.username or os.environ.get("REGISTRY_USERNAME")
password = args.password or os.environ.get("REGISTRY_PASSWORD")
if not all([registry, repo, username, password]):
print("[ERROR] Missing required arguments. Need: --registry, --repo, --username, --password")
print("Or set environment variables: REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD")
sys.exit(1)
print(f"Registry: {registry}")
print(f"Repository: {repo}")
print(f"Username: {username}")
try:
success = prune_tags(registry, repo, username, password, args.keep)
if success:
print("\n[OK] Prune completed successfully")
sys.exit(0)
else:
print("\n[WARN] Prune completed with some errors")
sys.exit(0) # Exit 0 per requirement - never fail workflow
except Exception as e:
print(f"\n[ERROR] Prune failed with exception: {e}")
sys.exit(0) # Exit 0 per requirement - never fail workflow
if __name__ == "__main__":
main()

View file

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Update GitOps kustomization.yaml with new image SHA tags.
Usage:
python3 update-gitops.py \
--repo /path/to/nxtgauge-gitops \
--service gateway \
--sha abc123def456...
This script:
1. Updates the newTag for the specified service to the SHA
2. Commits and pushes to the gitops repo
3. ArgoCD detects the change and deploys
"""
import argparse
import os
import re
import subprocess
import sys
def run(cmd: list[str], cwd: str = None) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
def update_kustomization(kustomization_path: str, service: str, sha: str) -> bool:
"""Update the newTag for a service in kustomization.yaml."""
with open(kustomization_path, "r") as f:
content = f.read()
# Pattern to find image entry for the service
# Matches: - name: registry.nxtgauge.com/nxtgauge-rust-{service}
# newTag: something
pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-rust-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
replacement = rf'\g<1>{sha}'
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
# Try without the nxtgauge-rust- prefix (for frontend, admin, etc)
pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
print(f"[ERROR] Could not find image entry for service: {service}")
return False
with open(kustomization_path, "w") as f:
f.write(new_content)
print(f"[OK] Updated {service} to SHA {sha}")
return True
def main():
parser = argparse.ArgumentParser(description="Update GitOps with new image SHA")
parser.add_argument("--repo", required=True, help="Path to gitops repo")
parser.add_argument("--service", required=True, help="Service name (e.g., gateway, users, frontend-solid)")
parser.add_argument("--sha", required=True, help="Git SHA to deploy")
parser.add_argument("--message", default=None, help="Commit message")
args = parser.parse_args()
service_image_map = {
"gateway": "nxtgauge-rust-gateway",
"users": "nxtgauge-rust-users",
"companies": "nxtgauge-rust-companies",
"jobs": "nxtgauge-rust-jobs",
"leads": "nxtgauge-rust-leads",
"job-seekers": "nxtgauge-rust-job-seekers",
"customers": "nxtgauge-rust-customers",
"payments": "nxtgauge-rust-payments",
"employees": "nxtgauge-rust-employees",
"photographers": "nxtgauge-rust-photographers",
"makeup-artists": "nxtgauge-rust-makeup-artists",
"tutors": "nxtgauge-rust-tutors",
"developers": "nxtgauge-rust-developers",
"video-editors": "nxtgauge-rust-video-editors",
"graphic-designers": "nxtgauge-rust-graphic-designers",
"social-media-managers": "nxtgauge-rust-social-media-managers",
"fitness-trainers": "nxtgauge-rust-fitness-trainers",
"catering-services": "nxtgauge-rust-catering-services",
"ugc-content-creators": "nxtgauge-rust-ugc-content-creators",
"cron": "nxtgauge-rust-cron",
"frontend-solid": "nxtgauge-frontend-solid",
"admin-solid": "nxtgauge-admin-solid",
"ai-assistant": "nxtgauge-ai-assistant",
}
# Determine which kustomization file to update
if service_image_map.get(args.service):
image_name = service_image_map[args.service]
else:
image_name = f"nxtgauge-{args.service}"
# Find the right kustomization file based on service
if "frontend" in args.service or "admin" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
elif "ai-assistant" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/base/kustomization.yaml")
else:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
print(f"[ERROR] Kustomization file not found: {kustomization_path}")
sys.exit(0) # Exit 0 per workflow requirement
print(f"Updating {kustomization_path} for service {args.service}")
if not update_kustomization(kustomization_path, args.service, args.sha):
sys.exit(0) # Exit 0 per workflow requirement
# Git add, commit, push
commit_msg = args.message or f"chore: deploy {args.service}@{args.sha}"
run(["git", "add", "-A"], cwd=args.repo)
code, stdout, stderr = run(["git", "diff", "--cached", "--stat"], cwd=args.repo)
if not stdout.strip():
print("[INFO] No changes to commit")
sys.exit(0)
print(f"Changes to commit:\n{stdout}")
run(["git", "commit", "-m", commit_msg], cwd=args.repo)
code, stdout, stderr = run(["git", "push"], cwd=args.repo)
if code != 0:
print(f"[ERROR] Push failed: {stderr}")
else:
print(f"[OK] Pushed update to gitops repo")
sys.exit(0) # Always exit 0 per workflow requirement
if __name__ == "__main__":
main()

View file

@ -0,0 +1,93 @@
name: build-and-push
on:
push:
branches:
- main
- high-performance
jobs:
build:
runs-on: ubuntu-latest
env:
DOCKER_HOST: unix:///var/run/docker.sock
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
run: |
export DOCKER_HOST=unix:///var/run/docker.sock
docker version
docker buildx create --use || true
docker buildx inspect --bootstrap
- name: Login to Registry
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
test -n "$REGISTRY_HOSTPORT"
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build and push
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
docker buildx build --push \
-f Dockerfile \
-t "$REGISTRY_HOSTPORT/nxtgauge-admin-solid:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-admin-solid:high-performance-latest" \
.
- name: Prune old image tags (keep latest 1 SHA)
if: success()
continue-on-error: true
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
python3 .gitea/scripts/registry_prune.py \
--registry "$REGISTRY_HOST" \
--repo "nxtgauge-admin-solid" \
--username "$REGISTRY_USERNAME" \
--password "$REGISTRY_PASSWORD" \
--keep 1
- name: Update GitOps and trigger deployment
if: success()
continue-on-error: true
env:
GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }}
GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "$GITEOPS_REPO" ]; then
echo "GITEOPS_REPO secret not set, skipping GitOps update"
exit 0
fi
GITEOPS_DIR=$(mktemp -d)
git clone "$GITEOPS_REPO" "$GITEOPS_DIR"
cd "$GITEOPS_DIR"
mkdir -p ~/.ssh
echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
python3 .gitea/scripts/update-gitops.py \
--repo "$GITEOPS_DIR" \
--service "admin-solid" \
--sha "${{ gitea.sha }}" \
--message "chore: deploy admin-solid@${{ gitea.sha }}"
rm -rf "$GITEOPS_DIR"

46
.github/workflows/sync-to-gitea.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: sync-to-gitea
on:
push:
branches:
- high-performance
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Sync to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_SECRET }}
REPO: ${{ github.event.repository.name }}
BRANCH: ${{ github.ref_name }}
run: |
set -euxo pipefail
export GIT_TERMINAL_PROMPT=0
export GIT_TRACE=1
export GIT_CURL_VERBOSE=1
USER="Admin"
TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git"
AUTH="$(printf '%s' "${USER}:${GITEA_TOKEN}" | base64 -w0)"
test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN empty" && exit 1)
curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null
curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global http.version HTTP/1.1
git config --global http.postBuffer 524288000
git remote remove gitea 2>/dev/null || true
git remote add gitea "${TARGET}"
git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force
git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,70 @@
- generic [ref=e2]:
- main [ref=e3]:
- generic:
- generic:
- generic:
- img
- generic:
- img
- generic:
- img
- generic:
- img
- generic:
- img
- navigation [ref=e5]:
- link "Nxtgauge home" [ref=e6] [cursor=pointer]:
- /url: /
- img "NXTGAUGE" [ref=e7]
- generic [ref=e8]:
- link "Home" [ref=e9] [cursor=pointer]:
- /url: /
- link "Professionals" [ref=e10] [cursor=pointer]:
- /url: /professionals
- link "About Us" [ref=e11] [cursor=pointer]:
- /url: /about
- link "Help Center" [ref=e12] [cursor=pointer]:
- /url: /help-center
- link "Contact Us" [ref=e13] [cursor=pointer]:
- /url: /contact
- link "Login" [ref=e15] [cursor=pointer]:
- /url: /login
- generic [ref=e16]:
- generic [ref=e17]:
- img "Public Workspace" [ref=e18]
- generic [ref=e20]:
- paragraph [ref=e21]: Public Workspace
- heading "Welcome Back To Nxtgauge" [level=1] [ref=e22]
- paragraph [ref=e23]: Sign in to manage your profile, portfolio, and verification in one place.
- generic [ref=e24]:
- heading "Sign In" [level=2] [ref=e25]
- generic [ref=e26]:
- generic [ref=e27]: EMAIL
- textbox "EMAIL" [ref=e28]:
- /placeholder: Enter your email
- paragraph [ref=e29]: • Enter a valid email format
- generic [ref=e30]:
- generic [ref=e31]: PASSWORD
- generic [ref=e32]:
- textbox "PASSWORD" [ref=e33]:
- /placeholder: Enter your password
- button "Show password" [ref=e34] [cursor=pointer]:
- img [ref=e35]
- generic [ref=e38]:
- generic [ref=e39]: CAPTCHA
- generic [ref=e40]:
- button "↻" [ref=e41]
- generic "Captcha image" [ref=e42]
- textbox "Enter captcha" [ref=e43]
- button "Sign In" [ref=e44]
- generic [ref=e45]:
- paragraph [ref=e46]: Secure login with email verification.
- paragraph [ref=e47]:
- text: New user?
- link "Sign Up" [ref=e48] [cursor=pointer]:
- /url: /signup
- paragraph [ref=e49]:
- link "Forgot Password?" [ref=e50] [cursor=pointer]:
- /url: /forgot-password
- button "AI Assistant" [ref=e51] [cursor=pointer]:
- img [ref=e52]

View file

@ -1,25 +0,0 @@
when:
branch: [main, high-performance]
event: push
steps:
- name: build-and-push
image: woodpeckerci/plugin-kaniko:2.1.1
settings:
registry:
from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-admin-solid
dockerfile: Dockerfile.simple
tags:
- ${CI_COMMIT_SHA}
- latest
- high-performance-latest
username:
from_secret: REGISTRY_USERNAME
password:
from_secret: REGISTRY_PASSWORD
insecure: true
insecure_pull: true
skip_tls_verify: true
platforms: linux/amd64
cache: false

View file

@ -27,7 +27,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
RUN npm run build
# Runtime stage
FROM node:20-alpine
FROM registry.nxtgauge.com/node:20-alpine
WORKDIR /app
# Copy built output

View file

@ -1,4 +1,4 @@
FROM node:20-alpine
FROM registry.nxtgauge.com/node:20-alpine
WORKDIR /app

View file

@ -7,6 +7,12 @@ Port admin modules one by one with strict API/permission parity.
See `docs/MIGRATION_MASTER_PLAN.md`.
## CI (Woodpecker)
Required secrets:
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD`
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
## Local Docker test (low RAM, no port conflict)

5
admin-solid.dev.log Normal file
View file

@ -0,0 +1,5 @@
> dev
> vinxi dev
vinxi v0.5.11

1
admin-solid.dev.pid Normal file
View file

@ -0,0 +1 @@
61044

1
admin-solid.start.log Normal file
View file

@ -0,0 +1 @@
Listening on http://[::]:3000

1
admin-solid.start.pid Normal file
View file

@ -0,0 +1 @@
72260

View file

@ -9,3 +9,74 @@ vinxi starting dev server
➜ Local: http://localhost:3000/
➜ Network: use --host to expose
1:30:54 PM [vite] (ssr) page reload vinxi/routes
1:30:54 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:30:54 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:31:04 PM [vite] (ssr) page reload vinxi/routes
1:31:04 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:31:04 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:31:15 PM [vite] (ssr) page reload vinxi/routes
1:31:15 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:31:16 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:31:47 PM [vite] (ssr) page reload vinxi/routes
1:31:47 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:31:47 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:32:06 PM [vite] (ssr) page reload vinxi/routes
1:32:06 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:32:06 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:32:13 PM [vite] (ssr) page reload vinxi/routes
1:32:13 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:32:13 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/employees/index.tsx?pick=default&pick=$css
1:39:55 PM [vite] (ssr) page reload vinxi/routes
1:39:55 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:39:55 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:40:16 PM [vite] (ssr) page reload vinxi/routes
1:40:16 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:40:16 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:42:29 PM [vite] (ssr) page reload vinxi/routes
1:42:29 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:42:29 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:43:02 PM [vite] (ssr) page reload vinxi/routes
1:43:02 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:43:02 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:43:06 PM [vite] (ssr) page reload vinxi/routes
1:43:06 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:43:06 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:46:00 PM [vite] (ssr) page reload vinxi/routes
1:46:00 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:46:00 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
1:47:24 PM [vite] (ssr) page reload vinxi/routes
1:47:24 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
1:47:24 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
3:43:28 PM [vite] (ssr) page reload vinxi/routes
3:43:28 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
3:43:28 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
3:43:29 PM [vite] (client) hmr invalidate /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
3:43:29 PM [vite] (client) page reload src/routes/admin/external-roles.tsx
3:49:14 PM [vite] (ssr) page reload vinxi/routes
3:49:14 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
3:49:14 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
3:50:11 PM [vite] (ssr) page reload vinxi/routes
3:50:11 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
3:50:11 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
3:50:36 PM [vite] (ssr) page reload vinxi/routes
3:50:36 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
3:50:36 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:15:08 PM [vite] (ssr) page reload vinxi/routes
5:15:08 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:15:09 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:15:14 PM [vite] (ssr) page reload vinxi/routes
5:15:14 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:15:15 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:15:21 PM [vite] (ssr) page reload vinxi/routes
5:15:21 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:15:21 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:15:38 PM [vite] (ssr) page reload vinxi/routes
5:15:38 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:15:38 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:31:17 PM [vite] (ssr) page reload vinxi/routes
5:31:17 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:31:17 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css
5:43:15 PM [vite] (ssr) page reload vinxi/routes
5:43:15 PM [vite] (client) hmr update /src/app.tsx, /src/app.css
5:43:15 PM [vite] (client) hmr update /@fs/Users/ashwin/workspace/nxtgauge-admin-solid/src/routes/admin/external-roles.tsx?pick=default&pick=$css

16
package-lock.json generated
View file

@ -7,8 +7,8 @@
"name": "nxtgauge-admin-solid",
"dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.3.2",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.3.0",
"@tailwindcss/vite": "^4.2.2",
"@thisbeyond/solid-dnd": "^0.7.5",
"apexcharts": "^5.10.4",
@ -2747,18 +2747,18 @@
}
},
"node_modules/@solidjs/router": {
"version": "0.15.4",
"resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.15.4.tgz",
"integrity": "sha512-WOpgg9a9T638cR+5FGbFi/IV4l2FpmBs1GpIMSPa0Ce9vyJN7Wts+X2PqMf9IYn0zUj2MlSJtm1gp7/HI/n5TQ==",
"version": "0.15.3",
"resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.15.3.tgz",
"integrity": "sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==",
"license": "MIT",
"peerDependencies": {
"solid-js": "^1.8.6"
}
},
"node_modules/@solidjs/start": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@solidjs/start/-/start-1.3.2.tgz",
"integrity": "sha512-tasDl3utVbtP0rr4InB3ntBIFV2upvEiFrOOCkRrAA3yBfjx9elpxnc94sJQXo65PNYdAAAkPIC6h93vLrtwHg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@solidjs/start/-/start-1.3.0.tgz",
"integrity": "sha512-FMqc0ZaAUIFBVOEUV87Y1W6LuCN5OveOigXvjZ9CarB/TQSC3QqDBSX+EyWkvreGIU7zsEIi0mka6NGJgJ5oOQ==",
"license": "MIT",
"dependencies": {
"@tanstack/server-functions-plugin": "1.121.21",

View file

@ -30,8 +30,8 @@
},
"dependencies": {
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.3.2",
"@solidjs/router": "^0.15.3",
"@solidjs/start": "^1.3.0",
"@tailwindcss/vite": "^4.2.2",
"@thisbeyond/solid-dnd": "^0.7.5",
"apexcharts": "^5.10.4",
@ -61,9 +61,9 @@
"pngjs": "^7.0.0",
"storybook": "^10.3.3",
"storybook-solidjs-vite": "^10.0.11",
"typescript": "^5.5.0",
"visbug": "^0.1.14",
"vitest": "^4.1.1",
"vite-plugin-solid": "^2.11.12",
"typescript": "^5.5.0"
"vitest": "^4.1.1"
}
}

35
screenshot.ts Normal file
View file

@ -0,0 +1,35 @@
import { chromium } from 'playwright';
async function takeNxtgaugeScreenshot() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
page.setViewportSize({ width: 1440, height: 900 });
console.log('Navigating to nxtgauge admin...');
await page.goto('http://localhost:3000/admin', { waitUntil: 'load' });
await page.waitForTimeout(5000);
// Take screenshot
console.log('Taking screenshot...');
await page.screenshot({ path: '/tmp/nxtgauge-admin.png', fullPage: true });
// Get sidebar structure info
const sidebarInfo = await page.evaluate(() => {
const sidebar = document.querySelector('aside');
if (sidebar) {
const styles = window.getComputedStyle(sidebar);
return {
width: styles.width,
background: styles.background,
borderRight: styles.borderRight,
};
}
return 'not found';
});
console.log('Sidebar styles:', sidebarInfo);
await browser.close();
console.log('Screenshot saved');
}
takeNxtgaugeScreenshot().catch(console.error);

View file

@ -7,7 +7,7 @@ import "./app.css";
export default function App() {
return (
<Router
root={props => (
root={(props) => (
<MetaProvider>
<Title>ADMIN PANEL | NXTGAUGE</Title>
<Suspense>{props.children}</Suspense>

View file

@ -1,172 +1,214 @@
import { A, useLocation, useNavigate, useSearchParams } from '@solidjs/router';
import { A, useLocation, useNavigate, useSearchParams } from "@solidjs/router";
import {
For, Show, createEffect, createMemo, createSignal,
onCleanup, onMount, type JSX,
} from 'solid-js';
import { Bell, Moon, Search, Settings, Sun, User } from 'lucide-solid';
import AdminSidebar from './AdminSidebar';
import { isExternalIdentity } from '~/lib/admin-auth';
import { clearAdminSession, hasAdminSession, setAdminSession } from '~/lib/admin-session';
import { normalizeAllowedModules } from '~/lib/admin/module-access';
For,
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
type JSX,
} from "solid-js";
import { Bell, Moon, Search, Settings, Sun, User } from "lucide-solid";
import AdminSidebar from "./AdminSidebar";
import { isExternalIdentity } from "~/lib/admin-auth";
import { clearAdminSession, hasAdminSession, setAdminSession } from "~/lib/admin-session";
import { normalizeAllowedModules } from "~/lib/admin/module-access";
type Tab = { href: string; label: string; exact?: boolean };
type SearchResult = { id: string; title: string; subtitle: string; href: string };
type SearchGroup = { label: string; viewAllHref: string; results: SearchResult[] };
const PAGE_TITLES: Array<{ prefix: string; label: string; exact?: boolean }> = [
{ prefix: '/admin', label: 'Dashboard', exact: true },
{ prefix: '/admin/department', label: 'Department Management' },
{ prefix: '/admin/designation', label: 'Designation Management' },
{ prefix: '/admin/roles', label: 'Internal Role Management' },
{ prefix: '/admin/employees', label: 'Employee Management' },
{ prefix: '/admin/external-roles', label: 'External Role Management' },
{ prefix: '/admin/internal-dashboard-management', label: 'Internal Dashboard Management' },
{ prefix: '/admin/external-dashboard-management', label: 'External Dashboard Management' },
{ prefix: '/admin/role-ui-configs', label: 'External Dashboard Management' },
{ prefix: '/admin/verification', label: 'Verification Management' },
{ prefix: '/admin/verification-status', label: 'Verification Management' },
{ prefix: '/admin/approval', label: 'Approval Management' },
{ prefix: '/admin/approvals', label: 'Approval Management' },
{ prefix: '/admin/approval-management', label: 'Approval Management' },
{ prefix: '/admin/users', label: 'Users Management' },
{ prefix: '/admin/company', label: 'Company Management' },
{ prefix: '/admin/candidate', label: 'Candidate Management' },
{ prefix: '/admin/customer', label: 'Customer Management' },
{ prefix: '/admin/photographer', label: 'Photographer Management' },
{ prefix: '/admin/makeup-artist', label: 'Makeup Artist Management' },
{ prefix: '/admin/tutors', label: 'Tutors Management' },
{ prefix: '/admin/developers', label: 'Developers Management' },
{ prefix: '/admin/video-editors', label: 'Video Editor Management' },
{ prefix: '/admin/fitness-trainers', label: 'Fitness Trainer Management' },
{ prefix: '/admin/catering-services', label: 'Catering Services Management' },
{ prefix: '/admin/ugc-content-creators', label: 'UGC Content Creator Management' },
{ prefix: '/admin/graphic-designers', label: 'Graphic Designer Management' },
{ prefix: '/admin/social-media-managers', label: 'Social Media Manager Management' },
{ prefix: '/admin/jobs', label: 'Jobs Management' },
{ prefix: '/admin/leads', label: 'Leads Management' },
{ prefix: '/admin/applications', label: 'Applications Management' },
{ prefix: '/admin/responses', label: 'Responses Management' },
{ prefix: '/admin/pricing', label: 'Pricing Management' },
{ prefix: '/admin/credit', label: 'Credit Management' },
{ prefix: '/admin/coupon', label: 'Coupon Management' },
{ prefix: '/admin/discount', label: 'Discount Management' },
{ prefix: '/admin/tax', label: 'Tax Management' },
{ prefix: '/admin/order', label: 'Order Management' },
{ prefix: '/admin/invoice', label: 'Invoice Management' },
{ prefix: '/admin/payment-gateway', label: 'Payment Gateway Management' },
{ prefix: '/admin/smtp', label: 'SMTP Management' },
{ prefix: '/admin/kb', label: 'Knowledge Base Management' },
{ prefix: '/admin/notifications', label: 'Notifications' },
{ prefix: '/admin/review', label: 'Review Management' },
{ prefix: '/admin/support', label: 'Support Management' },
{ prefix: '/admin/report', label: 'Report Management' },
{ prefix: '/admin/ledger', label: 'Ledger Management' },
{ prefix: "/admin", label: "Dashboard", exact: true },
{ prefix: "/admin/department", label: "Department Management" },
{ prefix: "/admin/designation", label: "Designation Management" },
{ prefix: "/admin/roles", label: "Internal Role Management" },
{ prefix: "/admin/employees", label: "Employee Management" },
{ prefix: "/admin/external-roles", label: "External Role Management" },
{ prefix: "/admin/internal-dashboard-management", label: "Internal Dashboard Management" },
{ prefix: "/admin/external-dashboard-management", label: "External Dashboard Management" },
{ prefix: "/admin/role-ui-configs", label: "External Dashboard Management" },
{ prefix: "/admin/verification", label: "Verification Management" },
{ prefix: "/admin/verification-status", label: "Verification Management" },
{ prefix: "/admin/approval", label: "Approval Management" },
{ prefix: "/admin/approvals", label: "Approval Management" },
{ prefix: "/admin/approval-management", label: "Approval Management" },
{ prefix: "/admin/users", label: "Users Management" },
{ prefix: "/admin/company", label: "Company Management" },
{ prefix: "/admin/candidate", label: "Candidate Management" },
{ prefix: "/admin/customer", label: "Customer Management" },
{ prefix: "/admin/photographer", label: "Photographer Management" },
{ prefix: "/admin/makeup-artist", label: "Makeup Artist Management" },
{ prefix: "/admin/tutors", label: "Tutors Management" },
{ prefix: "/admin/developers", label: "Developers Management" },
{ prefix: "/admin/video-editors", label: "Video Editor Management" },
{ prefix: "/admin/fitness-trainers", label: "Fitness Trainer Management" },
{ prefix: "/admin/catering-services", label: "Catering Services Management" },
{ prefix: "/admin/ugc-content-creators", label: "UGC Content Creator Management" },
{ prefix: "/admin/graphic-designers", label: "Graphic Designer Management" },
{ prefix: "/admin/social-media-managers", label: "Social Media Manager Management" },
{ prefix: "/admin/jobs", label: "Jobs Management" },
{ prefix: "/admin/leads", label: "Leads Management" },
{ prefix: "/admin/applications", label: "Applications Management" },
{ prefix: "/admin/responses", label: "Responses Management" },
{ prefix: "/admin/pricing", label: "Pricing Management" },
{ prefix: "/admin/credit", label: "Credit Management" },
{ prefix: "/admin/coupon", label: "Coupon Management" },
{ prefix: "/admin/discount", label: "Discount Management" },
{ prefix: "/admin/tax", label: "Tax Management" },
{ prefix: "/admin/order", label: "Order Management" },
{ prefix: "/admin/invoice", label: "Invoice Management" },
{ prefix: "/admin/payment-gateway", label: "Payment Gateway Management" },
{ prefix: "/admin/smtp", label: "SMTP Management" },
{ prefix: "/admin/kb", label: "Knowledge Base Management" },
{ prefix: "/admin/notifications", label: "Notifications" },
{ prefix: "/admin/review", label: "Review Management" },
{ prefix: "/admin/support", label: "Support Management" },
{ prefix: "/admin/report", label: "Report Management" },
{ prefix: "/admin/ledger", label: "Ledger Management" },
];
const TAB_SETS: Array<{ prefixes: string[]; tabs: Tab[] }> = [];
const ROUTE_MODULE_KEYS: Array<{ prefix: string; keys: string[] }> = [
{ prefix: '/admin', keys: ['ADMIN_DASHBOARD', 'DASHBOARD'] },
{ prefix: '/admin/department', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/department-management', keys: ['DEPARTMENT_MANAGEMENT', 'DEPARTMENTS'] },
{ prefix: '/admin/designation', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/designation-management', keys: ['DESIGNATION_MANAGEMENT', 'DESIGNATIONS'] },
{ prefix: '/admin/roles', keys: ['INTERNAL_ROLE_MANAGEMENT', 'ROLES'] },
{ prefix: '/admin/employees', keys: ['EMPLOYEE_MANAGEMENT', 'EMPLOYEES'] },
{ prefix: '/admin/external-roles', keys: ['EXTERNAL_ROLE_MANAGEMENT', 'EXTERNAL_ROLES'] },
{ prefix: '/admin/internal-dashboard-management', keys: ['INTERNAL_DASHBOARD_MANAGEMENT', 'INTERNAL_DASHBOARDS', 'INTERNAL_DASHBOARD_CONFIG'] },
{ prefix: '/admin/external-dashboard-management', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/role-ui-configs', keys: ['DASHBOARD_CONFIG_MANAGEMENT', 'EXTERNAL_DASHBOARD_MANAGEMENT', 'EXTERNAL_DASHBOARDS', 'EXTERNAL_DASHBOARD_CONFIG', 'RUNTIME_ROLES'] },
{ prefix: '/admin/verification', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/verification-status', keys: ['VERIFICATION_MANAGEMENT', 'VERIFICATIONS'] },
{ prefix: '/admin/approval', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approvals', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/approval-management', keys: ['APPROVAL_MANAGEMENT', 'APPROVALS'] },
{ prefix: '/admin/users', keys: ['USER_MANAGEMENT', 'USERS'] },
{ prefix: '/admin/company', keys: ['COMPANY_MANAGEMENT', 'COMPANIES'] },
{ prefix: '/admin/candidate', keys: ['CANDIDATE_MANAGEMENT', 'CANDIDATES'] },
{ prefix: '/admin/customer', keys: ['CUSTOMER_MANAGEMENT', 'CUSTOMERS'] },
{ prefix: '/admin/photographer', keys: ['PHOTOGRAPHER_MANAGEMENT', 'PHOTOGRAPHERS'] },
{ prefix: '/admin/makeup-artist', keys: ['MAKEUP_ARTIST_MANAGEMENT', 'MAKEUP_ARTISTS'] },
{ prefix: '/admin/tutors', keys: ['TUTOR_MANAGEMENT', 'TUTORS'] },
{ prefix: '/admin/developers', keys: ['DEVELOPER_MANAGEMENT', 'DEVELOPERS'] },
{ prefix: '/admin/video-editors', keys: ['VIDEO_EDITOR_MANAGEMENT', 'VIDEO_EDITORS'] },
{ prefix: '/admin/fitness-trainers', keys: ['FITNESS_TRAINER_MANAGEMENT', 'FITNESS_TRAINERS'] },
{ prefix: '/admin/catering-services', keys: ['CATERING_SERVICES_MANAGEMENT', 'CATERING_SERVICES'] },
{ prefix: '/admin/ugc-content-creator', keys: ['UGC_CONTENT_CREATOR_MANAGEMENT', 'UGC_CONTENT_CREATOR'] },
{ prefix: '/admin/graphic-designers', keys: ['GRAPHIC_DESIGNER_MANAGEMENT', 'GRAPHIC_DESIGNERS'] },
{ prefix: '/admin/social-media-managers', keys: ['SOCIAL_MEDIA_MANAGEMENT', 'SOCIAL_MEDIA_MANAGER_MANAGEMENT', 'SOCIAL_MEDIA_MANAGERS'] },
{ prefix: '/admin/jobs', keys: ['JOBS_MANAGEMENT', 'JOBS'] },
{ prefix: '/admin/leads', keys: ['LEADS_MANAGEMENT', 'LEADS', 'REQUIREMENTS_MANAGEMENT', 'REQUIREMENTS'] },
{ prefix: '/admin/applications', keys: ['APPLICATIONS_MANAGEMENT', 'APPLICATIONS'] },
{ prefix: '/admin/responses', keys: ['RESPONSES_MANAGEMENT', 'RESPONSES'] },
{ prefix: '/admin/pricing', keys: ['PRICING_MANAGEMENT', 'PRICING'] },
{ prefix: '/admin/credit', keys: ['CREDIT_MANAGEMENT', 'CREDITS'] },
{ prefix: '/admin/coupon', keys: ['COUPON_MANAGEMENT', 'COUPONS'] },
{ prefix: '/admin/discount', keys: ['DISCOUNT_MANAGEMENT', 'DISCOUNTS'] },
{ prefix: '/admin/tax', keys: ['TAX_MANAGEMENT', 'TAXES'] },
{ prefix: '/admin/order', keys: ['ORDER_MANAGEMENT', 'ORDERS'] },
{ prefix: '/admin/invoice', keys: ['INVOICE_MANAGEMENT', 'INVOICES'] },
{ prefix: '/admin/payment-gateway', keys: ['PAYMENT_GATEWAY_MANAGEMENT', 'PAYMENT_GATEWAY'] },
{ prefix: '/admin/smtp', keys: ['SMTP_MANAGEMENT', 'SMTP'] },
{ prefix: '/admin/kb', keys: ['KNOWLEDGE_BASE_MANAGEMENT', 'KNOWLEDGE_BASE', 'KB'] },
{ prefix: '/admin/notifications', keys: ['NOTIFICATIONS_MANAGEMENT', 'NOTIFICATIONS'] },
{ prefix: '/admin/review', keys: ['REVIEW_MANAGEMENT', 'REVIEWS'] },
{ prefix: '/admin/support', keys: ['SUPPORT_MANAGEMENT', 'SUPPORT'] },
{ prefix: '/admin/report', keys: ['REPORT_MANAGEMENT', 'REPORTS'] },
{ prefix: '/admin/ledger', keys: ['LEDGER', 'LEDGER_MANAGEMENT'] },
{ prefix: "/admin", keys: ["ADMIN_DASHBOARD", "DASHBOARD"] },
{ prefix: "/admin/department", keys: ["DEPARTMENT_MANAGEMENT", "DEPARTMENTS"] },
{ prefix: "/admin/department-management", keys: ["DEPARTMENT_MANAGEMENT", "DEPARTMENTS"] },
{ prefix: "/admin/designation", keys: ["DESIGNATION_MANAGEMENT", "DESIGNATIONS"] },
{ prefix: "/admin/designation-management", keys: ["DESIGNATION_MANAGEMENT", "DESIGNATIONS"] },
{ prefix: "/admin/roles", keys: ["INTERNAL_ROLE_MANAGEMENT", "ROLES"] },
{ prefix: "/admin/employees", keys: ["EMPLOYEE_MANAGEMENT", "EMPLOYEES"] },
{ prefix: "/admin/external-roles", keys: ["EXTERNAL_ROLE_MANAGEMENT", "EXTERNAL_ROLES"] },
{
prefix: "/admin/internal-dashboard-management",
keys: ["INTERNAL_DASHBOARD_MANAGEMENT", "INTERNAL_DASHBOARDS", "INTERNAL_DASHBOARD_CONFIG"],
},
{
prefix: "/admin/external-dashboard-management",
keys: [
"DASHBOARD_CONFIG_MANAGEMENT",
"EXTERNAL_DASHBOARD_MANAGEMENT",
"EXTERNAL_DASHBOARDS",
"EXTERNAL_DASHBOARD_CONFIG",
"RUNTIME_ROLES",
],
},
{
prefix: "/admin/role-ui-configs",
keys: [
"DASHBOARD_CONFIG_MANAGEMENT",
"EXTERNAL_DASHBOARD_MANAGEMENT",
"EXTERNAL_DASHBOARDS",
"EXTERNAL_DASHBOARD_CONFIG",
"RUNTIME_ROLES",
],
},
{ prefix: "/admin/verification", keys: ["VERIFICATION_MANAGEMENT", "VERIFICATIONS"] },
{ prefix: "/admin/verification-status", keys: ["VERIFICATION_MANAGEMENT", "VERIFICATIONS"] },
{ prefix: "/admin/approval", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/approvals", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/approval-management", keys: ["APPROVAL_MANAGEMENT", "APPROVALS"] },
{ prefix: "/admin/users", keys: ["USER_MANAGEMENT", "USERS"] },
{ prefix: "/admin/company", keys: ["COMPANY_MANAGEMENT", "COMPANIES"] },
{ prefix: "/admin/candidate", keys: ["CANDIDATE_MANAGEMENT", "CANDIDATES"] },
{ prefix: "/admin/customer", keys: ["CUSTOMER_MANAGEMENT", "CUSTOMERS"] },
{ prefix: "/admin/photographer", keys: ["PHOTOGRAPHER_MANAGEMENT", "PHOTOGRAPHERS"] },
{ prefix: "/admin/makeup-artist", keys: ["MAKEUP_ARTIST_MANAGEMENT", "MAKEUP_ARTISTS"] },
{ prefix: "/admin/tutors", keys: ["TUTOR_MANAGEMENT", "TUTORS"] },
{ prefix: "/admin/developers", keys: ["DEVELOPER_MANAGEMENT", "DEVELOPERS"] },
{ prefix: "/admin/video-editors", keys: ["VIDEO_EDITOR_MANAGEMENT", "VIDEO_EDITORS"] },
{ prefix: "/admin/fitness-trainers", keys: ["FITNESS_TRAINER_MANAGEMENT", "FITNESS_TRAINERS"] },
{
prefix: "/admin/catering-services",
keys: ["CATERING_SERVICES_MANAGEMENT", "CATERING_SERVICES"],
},
{
prefix: "/admin/ugc-content-creator",
keys: ["UGC_CONTENT_CREATOR_MANAGEMENT", "UGC_CONTENT_CREATOR"],
},
{
prefix: "/admin/graphic-designers",
keys: ["GRAPHIC_DESIGNER_MANAGEMENT", "GRAPHIC_DESIGNERS"],
},
{
prefix: "/admin/social-media-managers",
keys: ["SOCIAL_MEDIA_MANAGEMENT", "SOCIAL_MEDIA_MANAGER_MANAGEMENT", "SOCIAL_MEDIA_MANAGERS"],
},
{ prefix: "/admin/jobs", keys: ["JOBS_MANAGEMENT", "JOBS"] },
{
prefix: "/admin/leads",
keys: ["LEADS_MANAGEMENT", "LEADS", "REQUIREMENTS_MANAGEMENT", "REQUIREMENTS"],
},
{ prefix: "/admin/applications", keys: ["APPLICATIONS_MANAGEMENT", "APPLICATIONS"] },
{ prefix: "/admin/responses", keys: ["RESPONSES_MANAGEMENT", "RESPONSES"] },
{ prefix: "/admin/pricing", keys: ["PRICING_MANAGEMENT", "PRICING"] },
{ prefix: "/admin/credit", keys: ["CREDIT_MANAGEMENT", "CREDITS"] },
{ prefix: "/admin/coupon", keys: ["COUPON_MANAGEMENT", "COUPONS"] },
{ prefix: "/admin/discount", keys: ["DISCOUNT_MANAGEMENT", "DISCOUNTS"] },
{ prefix: "/admin/tax", keys: ["TAX_MANAGEMENT", "TAXES"] },
{ prefix: "/admin/order", keys: ["ORDER_MANAGEMENT", "ORDERS"] },
{ prefix: "/admin/invoice", keys: ["INVOICE_MANAGEMENT", "INVOICES"] },
{ prefix: "/admin/payment-gateway", keys: ["PAYMENT_GATEWAY_MANAGEMENT", "PAYMENT_GATEWAY"] },
{ prefix: "/admin/smtp", keys: ["SMTP_MANAGEMENT", "SMTP"] },
{ prefix: "/admin/kb", keys: ["KNOWLEDGE_BASE_MANAGEMENT", "KNOWLEDGE_BASE", "KB"] },
{ prefix: "/admin/notifications", keys: ["NOTIFICATIONS_MANAGEMENT", "NOTIFICATIONS"] },
{ prefix: "/admin/review", keys: ["REVIEW_MANAGEMENT", "REVIEWS"] },
{ prefix: "/admin/support", keys: ["SUPPORT_MANAGEMENT", "SUPPORT"] },
{ prefix: "/admin/report", keys: ["REPORT_MANAGEMENT", "REPORTS"] },
{ prefix: "/admin/ledger", keys: ["LEDGER", "LEDGER_MANAGEMENT"] },
];
const SEARCH_MODULES = [
{
label: 'Users',
viewAllHref: '/admin/users',
api: '/api/admin/users',
listKeys: ['users', 'items'],
titleKeys: ['full_name', 'name'],
subtitleKeys: ['email', 'phone'],
detailBase: '/admin/users',
label: "Users",
viewAllHref: "/admin/users",
api: "/api/admin/users",
listKeys: ["users", "items"],
titleKeys: ["full_name", "name"],
subtitleKeys: ["email", "phone"],
detailBase: "/admin/users",
},
{
label: 'Companies',
viewAllHref: '/admin/company',
api: '/api/admin/companies',
listKeys: ['companies', 'items'],
titleKeys: ['name', 'companyName'],
subtitleKeys: ['email', 'phone'],
detailBase: '/admin/company',
label: "Companies",
viewAllHref: "/admin/company",
api: "/api/admin/companies",
listKeys: ["companies", "items"],
titleKeys: ["name", "companyName"],
subtitleKeys: ["email", "phone"],
detailBase: "/admin/company",
},
{
label: 'Employees',
viewAllHref: '/admin/employees',
api: '/api/admin/employees',
listKeys: ['employees', 'items'],
titleKeys: ['full_name', 'name'],
subtitleKeys: ['email', 'department_name'],
detailBase: '/admin/employees',
label: "Employees",
viewAllHref: "/admin/employees",
api: "/api/admin/employees",
listKeys: ["employees", "items"],
titleKeys: ["full_name", "name"],
subtitleKeys: ["email", "department_name"],
detailBase: "/admin/employees",
},
{
label: 'Jobs',
viewAllHref: '/admin/jobs',
api: '/api/admin/jobs',
listKeys: ['jobs', 'items'],
titleKeys: ['title', 'name'],
subtitleKeys: ['status', 'company_name'],
detailBase: '/admin/jobs',
label: "Jobs",
viewAllHref: "/admin/jobs",
api: "/api/admin/jobs",
listKeys: ["jobs", "items"],
titleKeys: ["title", "name"],
subtitleKeys: ["status", "company_name"],
detailBase: "/admin/jobs",
},
{
label: 'Leads',
viewAllHref: '/admin/leads',
api: '/api/admin/leads',
listKeys: ['leads', 'items'],
titleKeys: ['name', 'full_name'],
subtitleKeys: ['email', 'status'],
detailBase: '/admin/leads',
label: "Leads",
viewAllHref: "/admin/leads",
api: "/api/admin/leads",
listKeys: ["leads", "items"],
titleKeys: ["name", "full_name"],
subtitleKeys: ["email", "status"],
detailBase: "/admin/leads",
},
];
function pickStr(obj: Record<string, any>, keys: string[]): string {
for (const k of keys) if (obj[k]) return String(obj[k]);
return '—';
return "—";
}
function extractList(data: any, keys: string[]): any[] {
@ -176,7 +218,7 @@ function extractList(data: any, keys: string[]): any[] {
}
function GlobalSearch() {
const [query, setQuery] = createSignal('');
const [query, setQuery] = createSignal("");
const [open, setOpen] = createSignal(false);
const [groups, setGroups] = createSignal<SearchGroup[]>([]);
const [searching, setSearching] = createSignal(false);
@ -185,11 +227,17 @@ function GlobalSearch() {
const doSearch = async (q: string) => {
const trimmed = q.trim();
if (trimmed.length < 2) { setGroups([]); setOpen(false); return; }
if (trimmed.length < 2) {
setGroups([]);
setOpen(false);
return;
}
setSearching(true);
const settled = await Promise.allSettled(
SEARCH_MODULES.map(async (mod) => {
const res = await fetch(`${mod.api}?search=${encodeURIComponent(trimmed)}&limit=4`).catch(() => null);
const res = await fetch(`${mod.api}?search=${encodeURIComponent(trimmed)}&limit=4`).catch(
() => null
);
if (!res?.ok) return null;
const data = await res.json().catch(() => null);
if (!data) return null;
@ -205,9 +253,9 @@ function GlobalSearch() {
href: `${mod.detailBase}/${item.id}`,
})),
} satisfies SearchGroup;
}),
})
);
setGroups(settled.flatMap((r) => (r.status === 'fulfilled' && r.value ? [r.value] : [])));
setGroups(settled.flatMap((r) => (r.status === "fulfilled" && r.value ? [r.value] : [])));
setOpen(true);
setSearching(false);
};
@ -215,26 +263,39 @@ function GlobalSearch() {
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timer);
if (val.trim().length < 2) { setGroups([]); setOpen(false); return; }
if (val.trim().length < 2) {
setGroups([]);
setOpen(false);
return;
}
timer = setTimeout(() => doSearch(val), 350);
};
const close = () => { setOpen(false); setQuery(''); setGroups([]); };
const onOutside = (e: MouseEvent) => { if (!wrapRef.contains(e.target as Node)) setOpen(false); };
const close = () => {
setOpen(false);
setQuery("");
setGroups([]);
};
const onOutside = (e: MouseEvent) => {
if (!wrapRef.contains(e.target as Node)) setOpen(false);
};
onMount(() => document.addEventListener('mousedown', onOutside));
onCleanup(() => document.removeEventListener('mousedown', onOutside));
onMount(() => document.addEventListener("mousedown", onOutside));
onCleanup(() => document.removeEventListener("mousedown", onOutside));
return (
<div ref={wrapRef!} class="relative ml-10 w-[560px] shrink-0">
<Search size={20} class="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-[#9498ad]" />
<Search
size={20}
class="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 text-[#9498ad]"
/>
<input
type="text"
value={query()}
placeholder="Search system resources..."
onInput={(e) => handleInput(e.currentTarget.value)}
onFocus={() => groups().length > 0 && setOpen(true)}
onKeyDown={(e) => e.key === 'Escape' && close()}
onKeyDown={(e) => e.key === "Escape" && close()}
class="h-[68px] w-full rounded-[24px] border-2 border-transparent bg-[#f4f5f8] pl-[60px] pr-6 text-[16px] text-[#0D0D2A] placeholder:text-[rgba(13,13,42,0.4)] outline-none transition-all focus:border-[#e5e7eb] focus:bg-white"
/>
@ -244,19 +305,35 @@ function GlobalSearch() {
{(group) => (
<div class="border-b border-[#f1f2f5] px-4 py-3 last:border-b-0">
<div class="mb-2 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-[0.12em] text-[#9aa0b9]">{group.label}</span>
<A href={group.viewAllHref} onClick={close} class="text-[12px] font-semibold text-[#FF5E13]">View all</A>
<span class="text-[10px] font-bold uppercase tracking-[0.12em] text-[#9aa0b9]">
{group.label}
</span>
<A
href={group.viewAllHref}
onClick={close}
class="text-[12px] font-semibold text-[#FF5E13]"
>
View all
</A>
</div>
<div class="space-y-1">
<For each={group.results}>
{(item) => (
<A href={item.href} onClick={close} class="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-[#f9fafb]">
<A
href={item.href}
onClick={close}
class="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-[#f9fafb]"
>
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-[rgba(255,94,19,0.12)] text-[12px] font-bold text-[#FF5E13]">
{item.title.trim().slice(0, 1).toUpperCase()}
</div>
<div class="min-w-0">
<p class="truncate text-[13px] font-semibold text-[#0D0D2A]">{item.title}</p>
<p class="truncate text-[12px] text-[rgba(13,13,42,0.55)]">{item.subtitle}</p>
<p class="truncate text-[13px] font-semibold text-[#0D0D2A]">
{item.title}
</p>
<p class="truncate text-[12px] text-[rgba(13,13,42,0.55)]">
{item.subtitle}
</p>
</div>
</A>
)}
@ -281,20 +358,27 @@ function ShowTabs(props: {
tabs: Tab[];
isTabActive: (tab: Tab) => boolean;
setTabsTrackEl: (el: HTMLDivElement) => void;
setTabRefs: (fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>) => void;
setTabRefs: (
fn: (prev: Record<string, HTMLAnchorElement>) => Record<string, HTMLAnchorElement>
) => void;
tabIndicator: () => { left: number; width: number; ready: boolean };
}) {
if (props.tabs.length === 0) return null;
return (
<div ref={props.setTabsTrackEl} class="relative mb-6 mt-1 flex items-center gap-1 border-b border-[#e5e7eb]">
<div
ref={props.setTabsTrackEl}
class="relative mb-6 mt-1 flex items-center gap-1 border-b border-[#e5e7eb]"
>
<For each={props.tabs}>
{(tab) => (
<A
href={tab.href}
ref={(el) => props.setTabRefs((prev) => ({ ...prev, [tab.href]: el }))}
aria-current={props.isTabActive(tab) ? 'page' : undefined}
aria-current={props.isTabActive(tab) ? "page" : undefined}
class={`px-4 pb-3 pt-3 text-[14px] font-semibold transition-colors ${
props.isTabActive(tab) ? 'text-[#FF5E13]' : 'text-[rgba(13,13,42,0.6)] hover:text-[#0D0D2A]'
props.isTabActive(tab)
? "text-[#FF5E13]"
: "text-[rgba(13,13,42,0.6)] hover:text-[#0D0D2A]"
}`}
>
{tab.label}
@ -302,7 +386,7 @@ function ShowTabs(props: {
)}
</For>
<div
class={`absolute bottom-0 h-[2px] bg-[#FF5E13] transition-all duration-300 ease-out ${props.tabIndicator().ready ? 'opacity-100' : 'opacity-0'}`}
class={`absolute bottom-0 h-[2px] bg-[#FF5E13] transition-all duration-300 ease-out ${props.tabIndicator().ready ? "opacity-100" : "opacity-0"}`}
style={{ left: `${props.tabIndicator().left}px`, width: `${props.tabIndicator().width}px` }}
/>
</div>
@ -314,14 +398,14 @@ export default function AdminShell(props: { children: JSX.Element }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [checkedSession, setCheckedSession] = createSignal(false);
const [adminName, setAdminName] = createSignal('Admin User');
const [checkedSession, setCheckedSession] = createSignal(true);
const [adminName, setAdminName] = createSignal("Admin User");
const [allowedModules, setAllowedModules] = createSignal<string[] | null>(null);
const [isSuperAdmin, setIsSuperAdmin] = createSignal(false);
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const [sidebarCollapsed, setSidebarCollapsed] = createSignal(false);
const [unreadCount, setUnreadCount] = createSignal(0);
const [theme, setTheme] = createSignal<'light' | 'dark'>('light');
const [theme, setTheme] = createSignal<"light" | "dark">("light");
const [routeTransitioning, setRouteTransitioning] = createSignal(false);
const [tabsTrackEl, setTabsTrackEl] = createSignal<HTMLDivElement>();
@ -331,25 +415,26 @@ export default function AdminShell(props: { children: JSX.Element }) {
const logout = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
await fetch('/api/auth/logout', {
method: 'POST',
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
await fetch("/api/auth/logout", {
method: "POST",
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
}).catch(() => null);
} finally {
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('nxtgauge_admin_access_token');
sessionStorage.removeItem('nxtgauge_admin_preview');
if (typeof sessionStorage !== "undefined") {
sessionStorage.removeItem("nxtgauge_admin_access_token");
sessionStorage.removeItem("nxtgauge_admin_preview");
}
clearAdminSession();
navigate('/login', { replace: true });
navigate("/login", { replace: true });
}
};
@ -369,7 +454,10 @@ export default function AdminShell(props: { children: JSX.Element }) {
const refreshTabIndicator = () => {
const activeTab = tabs().find((tab) => isTabActive(tab));
const track = tabsTrackEl();
if (!activeTab || !track) { setTabIndicator((p) => ({ ...p, ready: false })); return; }
if (!activeTab || !track) {
setTabIndicator((p) => ({ ...p, ready: false }));
return;
}
const el = tabRefs()[activeTab.href];
if (!el) return;
setTabIndicator({ left: el.offsetLeft, width: el.offsetWidth, ready: true });
@ -383,56 +471,47 @@ export default function AdminShell(props: { children: JSX.Element }) {
createEffect(() => {
location.pathname;
setRouteTransitioning(true);
requestAnimationFrame(() => {
requestAnimationFrame(() => setRouteTransitioning(false));
});
if (!contentScrollRef) return;
const prefersReducedMotion = typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false;
contentScrollRef.scrollTo({
top: 0,
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
if (contentScrollRef) {
contentScrollRef.scrollTop = 0;
}
});
onMount(() => {
const savedTheme = (typeof localStorage !== 'undefined'
? localStorage.getItem('nxtgauge_admin_theme')
: null) as 'light' | 'dark' | null;
const nextTheme = savedTheme === 'dark' ? 'dark' : 'light';
const savedTheme = (
typeof localStorage !== "undefined" ? localStorage.getItem("nxtgauge_admin_theme") : null
) as "light" | "dark" | null;
const nextTheme = savedTheme === "dark" ? "dark" : "light";
setTheme(nextTheme);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', nextTheme);
if (typeof document !== "undefined") {
document.documentElement.setAttribute("data-theme", nextTheme);
}
window.addEventListener('resize', refreshTabIndicator);
onCleanup(() => window.removeEventListener('resize', refreshTabIndicator));
window.addEventListener("resize", refreshTabIndicator);
onCleanup(() => window.removeEventListener("resize", refreshTabIndicator));
// Fetch unread notification count and poll every 30 seconds
const fetchUnreadCount = async () => {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
if (!accessToken) return;
const res = await fetch('/api/me/notifications/unread-count', {
method: 'GET',
const res = await fetch("/api/me/notifications/unread-count", {
method: "GET",
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (res.ok) {
const data = await res.json();
setUnreadCount(data.unread_count || 0);
}
} catch (e) {
console.error('Failed to fetch unread count:', e);
console.error("Failed to fetch unread count:", e);
}
};
@ -440,11 +519,14 @@ export default function AdminShell(props: { children: JSX.Element }) {
const interval = setInterval(fetchUnreadCount, 30000);
onCleanup(() => clearInterval(interval));
const isPreview = searchParams._preview === '1' ||
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('nxtgauge_admin_preview') === '1');
const isPreview =
searchParams._preview === "1" ||
(typeof sessionStorage !== "undefined" &&
sessionStorage.getItem("nxtgauge_admin_preview") === "1");
if (isPreview) {
if (typeof sessionStorage !== 'undefined') sessionStorage.setItem('nxtgauge_admin_preview', '1');
if (typeof sessionStorage !== "undefined")
sessionStorage.setItem("nxtgauge_admin_preview", "1");
setAdminSession();
setCheckedSession(true);
return;
@ -452,52 +534,57 @@ export default function AdminShell(props: { children: JSX.Element }) {
const verify = async () => {
if (!hasAdminSession()) {
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
replace: true,
});
return;
}
try {
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const response = await fetch('/api/auth/session', {
method: 'GET',
const accessToken =
typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
const response = await fetch("/api/auth/session", {
method: "GET",
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || isExternalIdentity(payload)) throw new Error('Unauthorized');
if (!response.ok || isExternalIdentity(payload)) throw new Error("Unauthorized");
if (payload?.full_name) setAdminName(payload.full_name);
const roleKey = String(
payload?.active_role
|| payload?.role
|| payload?.user?.active_role
|| payload?.user?.active_role_key
|| payload?.user?.role
|| payload?.user?.role_key
|| '',
payload?.active_role ||
payload?.role ||
payload?.user?.active_role ||
payload?.user?.active_role_key ||
payload?.user?.role ||
payload?.user?.role_key ||
""
).toUpperCase();
setIsSuperAdmin(roleKey === 'SUPER_ADMIN');
setIsSuperAdmin(roleKey === "SUPER_ADMIN");
try {
const res = await fetch('/api/runtime-config', {
method: 'GET',
const res = await fetch("/api/runtime-config", {
method: "GET",
headers: {
Accept: 'application/json',
'x-portal-target': 'admin',
Accept: "application/json",
"x-portal-target": "admin",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
const runtime = await res.json().catch(() => ({}));
if (res.ok) {
setAllowedModules(normalizeAllowedModules(runtime));
const activeRole = String(runtime?.active_role || runtime?.user?.active_role || roleKey || '').toUpperCase();
if (activeRole) setIsSuperAdmin(activeRole === 'SUPER_ADMIN');
const activeRole = String(
runtime?.active_role || runtime?.user?.active_role || roleKey || ""
).toUpperCase();
if (activeRole) setIsSuperAdmin(activeRole === "SUPER_ADMIN");
} else {
setAllowedModules(null);
}
@ -508,7 +595,9 @@ export default function AdminShell(props: { children: JSX.Element }) {
setCheckedSession(true);
} catch {
clearAdminSession();
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, { replace: true });
navigate(`/login?from=${encodeURIComponent(location.pathname + location.search)}`, {
replace: true,
});
}
};
@ -518,29 +607,36 @@ export default function AdminShell(props: { children: JSX.Element }) {
const pageTitle = createMemo(() => {
const path = location.pathname;
for (const entry of PAGE_TITLES) {
if (entry.exact ? path === entry.prefix : (path === entry.prefix || path.startsWith(`${entry.prefix}/`))) {
if (
entry.exact
? path === entry.prefix
: path === entry.prefix || path.startsWith(`${entry.prefix}/`)
) {
return entry.label;
}
}
return 'Admin';
return "Admin";
});
const adminInitials = createMemo(() => {
if (adminName().trim().toLowerCase() === 'admin user') return 'AD';
const parts = adminName().split(' ').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return 'U';
if (adminName().trim().toLowerCase() === "admin user") return "AD";
const parts = adminName()
.split(" ")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length === 0) return "U";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
});
createEffect(() => {
const t = theme();
if (typeof localStorage !== 'undefined') localStorage.setItem('nxtgauge_admin_theme', t);
if (typeof document !== 'undefined') document.documentElement.setAttribute('data-theme', t);
if (typeof localStorage !== "undefined") localStorage.setItem("nxtgauge_admin_theme", t);
if (typeof document !== "undefined") document.documentElement.setAttribute("data-theme", t);
});
const toggleTheme = () => setTheme((v) => (v === 'dark' ? 'light' : 'dark'));
const isDark = () => theme() === 'dark';
const toggleTheme = () => setTheme((v) => (v === "dark" ? "light" : "dark"));
const isDark = () => theme() === "dark";
createEffect(() => {
if (!checkedSession()) return;
@ -550,29 +646,53 @@ export default function AdminShell(props: { children: JSX.Element }) {
if (!modules || modules.length === 0) return;
const path = location.pathname;
if (path === '/admin') return;
if (path === "/admin") return;
const matches = ROUTE_MODULE_KEYS.filter(
(entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`),
(entry) => path === entry.prefix || path.startsWith(`${entry.prefix}/`)
);
const guard = matches.sort((a, b) => b.prefix.length - a.prefix.length)[0];
if (!guard) return;
const allowed = new Set(modules.map((m) => String(m || '').trim().toUpperCase()).filter(Boolean));
const allowed = new Set(
modules
.map((m) =>
String(m || "")
.trim()
.toUpperCase()
)
.filter(Boolean)
);
const ok = guard.keys.some((k) => allowed.has(String(k).toUpperCase()));
if (ok) return;
navigate(`/admin?denied=${encodeURIComponent(guard.keys[0])}&from=${encodeURIComponent(path)}`, { replace: true });
navigate(
`/admin?denied=${encodeURIComponent(guard.keys[0])}&from=${encodeURIComponent(path)}`,
{ replace: true }
);
});
return (
<div class="min-h-screen" style={{ background: isDark() ? '#0B1220' : '#F9FAFB', color: isDark() ? '#E5E7EB' : '#0D0D2A' }}>
<div
class="min-h-screen"
style={{
background: isDark() ? "#0B1220" : "#F9FAFB",
color: isDark() ? "#E5E7EB" : "#0D0D2A",
}}
>
<Show
when={checkedSession()}
fallback={<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(13,13,42,0.55)]">Checking session</div>}
fallback={
<div class="flex min-h-screen items-center justify-center text-[14px] text-[rgba(13,13,42,0.55)]">
Checking session
</div>
}
>
<div style="display:flex;height:100vh;overflow:hidden">
<div class={`fixed inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${sidebarOpen() ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} onClick={() => setSidebarOpen(false)} />
<div
class={`fixed inset-0 z-20 bg-black/30 transition-opacity lg:hidden ${sidebarOpen() ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
onClick={() => setSidebarOpen(false)}
/>
<div style="height:100%;display:flex;flex-shrink:0">
<AdminSidebar
@ -588,24 +708,43 @@ export default function AdminShell(props: { children: JSX.Element }) {
</div>
<div class="flex min-w-0 flex-1 flex-col">
<header style={`height:64px;border-bottom:1px solid ${isDark() ? '#1F2937' : '#E5E7EB'};background:${isDark() ? '#111827' : 'white'};flex-shrink:0`}>
<header
style={`height:64px;border-bottom:1px solid ${isDark() ? "#1F2937" : "#E5E7EB"};background:${isDark() ? "#111827" : "white"};flex-shrink:0`}
>
<div style="display:flex;height:100%;width:100%;align-items:center;justify-content:flex-end;padding:0 32px">
<div style="display:flex;align-items:center;gap:4px">
<button type="button" onClick={toggleTheme} style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Toggle theme">
<button
type="button"
onClick={toggleTheme}
style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Toggle theme"
>
<Show when={isDark()} fallback={<Moon size={18} />}>
<Sun size={18} />
</Show>
</button>
<button type="button" style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Notifications">
<Bell size={18} />
<Show when={unreadCount() > 0}>
<span style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? '#111827' : 'white'};background:#FF5E13`} />
</Show>
</button>
<button type="button" style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? '#CBD5E1' : '#6B7280'};background:none;border:none;cursor:pointer`} aria-label="Settings">
<button
type="button"
style={`position:relative;display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Notifications"
>
<Bell size={18} />
<Show when={unreadCount() > 0}>
<span
style={`position:absolute;right:8px;top:8px;width:7px;height:7px;border-radius:50%;border:2px solid ${isDark() ? "#111827" : "white"};background:#FF5E13`}
/>
</Show>
</button>
<button
type="button"
style={`display:inline-flex;width:36px;height:36px;align-items:center;justify-content:center;border-radius:8px;color:${isDark() ? "#CBD5E1" : "#6B7280"};background:none;border:none;cursor:pointer`}
aria-label="Settings"
>
<Settings size={18} />
</button>
<div style={`width:1px;height:24px;background:${isDark() ? '#1F2937' : '#E5E7EB'};margin:0 8px`} />
<div
style={`width:1px;height:24px;background:${isDark() ? "#1F2937" : "#E5E7EB"};margin:0 8px`}
/>
<button
type="button"
style="display:inline-flex;align-items:center;gap:8px;border-radius:8px;padding:4px 8px 4px 4px;background:none;border:none;cursor:pointer"
@ -615,14 +754,22 @@ export default function AdminShell(props: { children: JSX.Element }) {
{adminInitials()}
</div>
<div style="text-align:left">
<p style={`font-size:13px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#111827'};line-height:1.3`}>{adminName()}</p>
<p style={`font-size:11px;color:${isDark() ? '#94A3B8' : '#6B7280'};line-height:1.3`}>Super Admin</p>
<p
style={`font-size:13px;font-weight:600;color:${isDark() ? "#E5E7EB" : "#111827"};line-height:1.3`}
>
{adminName()}
</p>
<p
style={`font-size:11px;color:${isDark() ? "#94A3B8" : "#6B7280"};line-height:1.3`}
>
Super Admin
</p>
</div>
</button>
<button
type="button"
onClick={() => void logout()}
style={`height:32px;border-radius:8px;border:1px solid ${isDark() ? '#374151' : '#E5E7EB'};background:${isDark() ? '#1F2937' : 'white'};padding:0 12px;font-size:12px;font-weight:600;color:${isDark() ? '#E5E7EB' : '#374151'};cursor:pointer`}
style={`height:32px;border-radius:8px;border:1px solid ${isDark() ? "#374151" : "#E5E7EB"};background:${isDark() ? "#1F2937" : "white"};padding:0 12px;font-size:12px;font-weight:600;color:${isDark() ? "#E5E7EB" : "#374151"};cursor:pointer`}
>
Logout
</button>
@ -631,18 +778,20 @@ export default function AdminShell(props: { children: JSX.Element }) {
</header>
<div
ref={(el) => { contentScrollRef = el; }}
ref={(el) => {
contentScrollRef = el;
}}
class="min-h-0 flex-1 overflow-y-scroll"
style={{ background: isDark() ? '#0B1220' : '#F9FAFB', 'scrollbar-gutter': 'stable' }}
style={{ background: isDark() ? "#0B1220" : "#F9FAFB", "scrollbar-gutter": "stable" }}
>
<main
class="admin-main"
style={{
width: '100%',
padding: '28px 24px 36px 24px',
filter: isDark() ? 'brightness(0.96)' : 'none',
transition: 'opacity 150ms ease',
opacity: routeTransitioning() ? '0.92' : '1',
width: "100%",
padding: "28px 24px 36px 24px",
filter: isDark() ? "brightness(0.96)" : "none",
transition: "opacity 150ms ease",
opacity: routeTransitioning() ? "0.92" : "1",
}}
>
{props.children}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,18 @@ const SESSION_TTL_SECONDS = 60 * 60 * 12;
export function hasAdminSession(): boolean {
if (typeof document === 'undefined') return false;
return document.cookie.split(';').some((entry) => entry.trim() === `${SESSION_COOKIE}=${SESSION_VALUE}`);
// Check cookie exists
const hasCookie = document.cookie.split(';').some((entry) => entry.trim() === `${SESSION_COOKIE}=${SESSION_VALUE}`);
// Also check if sessionStorage has a valid token as fallback
const hasToken = (() => {
try {
const token = sessionStorage.getItem('nxtgauge_admin_access_token');
return Boolean(token && token.trim().length > 0);
} catch {
return false;
}
})();
return hasCookie || hasToken;
}
export function setAdminSession(): void {

View file

@ -20,15 +20,18 @@ export function forwardCookies(request: Request): Record<string, string> {
/**
* Merge auth + cookie headers from the incoming request with any extra headers provided.
* Extra headers (e.g. Content-Type: multipart/form-data for file uploads) take precedence
* over the default application/json.
*/
export function withAuthHeaders(
request: Request,
extra: Record<string, string> = {},
): Record<string, string> {
return {
'Content-Type': 'application/json',
...forwardAuth(request),
...forwardCookies(request),
...extra,
// Default Content-Type only if extra didn't already provide one
...(extra['Content-Type'] ? {} : { 'Content-Type': 'application/json' }),
};
}

View file

@ -1,36 +1,37 @@
import { A, useParams } from '@solidjs/router';
import { createMemo } from 'solid-js';
import ApprovalManagementPage from './approval';
import VerificationManagementPage from './verification';
import UsersManagementPage from './users';
import ExternalDashboardManagementPage from './external-dashboard-management';
import InternalDashboardManagementPage from './internal-dashboard-management';
import { A, useParams } from "@solidjs/router";
import { createMemo, lazy } from "solid-js";
const ApprovalManagementPage = lazy(() => import("./approval"));
const VerificationManagementPage = lazy(() => import("./verification"));
const UsersManagementPage = lazy(() => import("./users"));
const ExternalDashboardManagementPage = lazy(() => import("./external-dashboard-management"));
const InternalDashboardManagementPage = lazy(() => import("./internal-dashboard-management"));
function toTitle(value: string): string {
return value
.split(/[-_/]/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
.join(" ");
}
const LEGACY_ADMIN_ORIGIN = import.meta.env.VITE_LEGACY_ADMIN_ORIGIN || 'http://localhost:9201';
const LEGACY_ADMIN_ORIGIN = import.meta.env.VITE_LEGACY_ADMIN_ORIGIN || "http://localhost:9201";
function resolveLegacyPath(modulePath: string): string {
switch (modulePath) {
case 'roles':
return '/roles?scope=internal';
case 'approval-management':
case 'approvals':
return '/approval';
case 'onboarding-management':
return '/external-dashboard-management';
case 'internal-dashboard-management':
return '/internal-dashboard-management';
case 'external-dashboard-management':
return '/external-dashboard-management';
case 'support':
return '/help';
case "roles":
return "/roles?scope=internal";
case "approval-management":
case "approvals":
return "/approval";
case "onboarding-management":
return "/external-dashboard-management";
case "internal-dashboard-management":
return "/internal-dashboard-management";
case "external-dashboard-management":
return "/external-dashboard-management";
case "support":
return "/help";
default:
return `/${modulePath}`;
}
@ -38,29 +39,42 @@ function resolveLegacyPath(modulePath: string): string {
export default function LegacyModuleShellPage() {
const params = useParams();
const modulePath = String((params as any).module || '').trim();
const modulePath = String((params as any).module || "").trim();
if (modulePath === 'approval' || modulePath === 'approval-management' || modulePath === 'approvals' || modulePath === 'approval-status') {
if (
modulePath === "approval" ||
modulePath === "approval-management" ||
modulePath === "approvals" ||
modulePath === "approval-status"
) {
return <ApprovalManagementPage />;
}
if (modulePath === 'verification' || modulePath === 'verification-status' || modulePath === 'verification-management') {
if (
modulePath === "verification" ||
modulePath === "verification-status" ||
modulePath === "verification-management"
) {
return <VerificationManagementPage />;
}
if (modulePath === 'users' || modulePath === 'users-management' || modulePath === 'user-management') {
if (
modulePath === "users" ||
modulePath === "users-management" ||
modulePath === "user-management"
) {
return <UsersManagementPage />;
}
if (modulePath === 'external-dashboard-management' || modulePath === 'onboarding-management') {
if (modulePath === "external-dashboard-management" || modulePath === "onboarding-management") {
return <ExternalDashboardManagementPage />;
}
if (modulePath === 'internal-dashboard-management') {
if (modulePath === "internal-dashboard-management") {
return <InternalDashboardManagementPage />;
}
const moduleName = createMemo(() => toTitle(modulePath || 'Management'));
const moduleName = createMemo(() => toTitle(modulePath || "Management"));
const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
@ -72,12 +86,24 @@ export default function LegacyModuleShellPage() {
</p>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="actions">
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={legacyUrl()} target="_blank">Open Module In New Tab</A>
<A
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={legacyUrl()}
target="_blank"
>
Open Module In New Tab
</A>
</div>
<iframe
src={legacyUrl()}
title={`${moduleName()} (Legacy)`}
style={{ width: '100%', height: '72vh', border: '1px solid #e2e8f0', 'border-radius': '10px', 'margin-top': '10px' }}
style={{
width: "100%",
height: "72vh",
border: "1px solid #e2e8f0",
"border-radius": "10px",
"margin-top": "10px",
}}
/>
</section>
</div>

View file

@ -1,42 +1,42 @@
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
function authHeaders(): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
const ROLE_OPTIONS = [
'company',
'customer',
'job_seeker',
'photographer',
'video_editor',
'graphic_designer',
'social_media_manager',
'fitness_trainer',
'catering_services',
'makeup_artist',
'tutor',
'developer',
"company",
"customer",
"job_seeker",
"photographer",
"video_editor",
"graphic_designer",
"social_media_manager",
"fitness_trainer",
"catering_services",
"makeup_artist",
"tutor",
"developer",
];
type Coupon = {
id: string;
code: string;
title: string;
type: 'PERCENT' | 'FIXED';
type: "PERCENT" | "FIXED";
value: number;
min_order_amount: number;
used_count: number;
@ -45,44 +45,50 @@ type Coupon = {
role_keys: string[];
};
const defaultForm = () => ({
id: '',
code: '',
title: '',
type: 'PERCENT' as 'PERCENT' | 'FIXED',
id: "",
code: "",
title: "",
type: "PERCENT" as "PERCENT" | "FIXED",
value: 10,
min_order_amount: 0,
max_uses: '',
role_keys: ['company', 'customer'] as string[],
max_uses: "",
applies_to: "ALL" as "ALL" | "ROLE",
role_keys: ["company", "customer"] as string[],
});
export default function CouponPage() {
const [coupons, setCoupons] = createSignal<Coupon[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const [activeTab, setActiveTab] = createSignal<'list' | 'create'>('list');
const [loadError, setLoadError] = createSignal("");
const [activeTab, setActiveTab] = createSignal<"list" | "create">("list");
const [form, setForm] = createSignal(defaultForm());
const [saving, setSaving] = createSignal(false);
const [toggling, setToggling] = createSignal('');
const [formError, setFormError] = createSignal('');
const [toggling, setToggling] = createSignal("");
const [formError, setFormError] = createSignal("");
// Filters
const [search, setSearch] = createSignal('');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'code_asc' | 'code_desc'>('newest');
const [search, setSearch] = createSignal("");
const [statusFilter, setStatusFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "code_asc" | "code_desc">(
"newest"
);
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
const load = async () => {
setLoading(true); setLoadError('');
setLoading(true);
setLoadError("");
try {
const res = await fetch(`${API}/api/admin/coupons`, { headers: authHeaders(), credentials: 'include' });
const res = await fetch(`${API}/api/admin/coupons`, {
headers: authHeaders(),
credentials: "include",
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const data = await res.json();
setCoupons(Array.isArray(data) ? data : (data.coupons ?? []));
} catch (err: any) {
setLoadError(err.message || 'Could not load coupons.');
setLoadError(err.message || "Could not load coupons.");
setCoupons([]);
} finally {
setLoading(false);
@ -94,59 +100,62 @@ export default function CouponPage() {
const filteredCoupons = createMemo(() => {
let r = coupons();
const q = search().toLowerCase();
if (q) r = r.filter((c) => c.code.toLowerCase().includes(q) || (c.title || '').toLowerCase().includes(q));
if (statusFilter() === 'active') r = r.filter((c) => c.is_active);
if (statusFilter() === 'inactive') r = r.filter((c) => !c.is_active);
if (q)
r = r.filter(
(c) => c.code.toLowerCase().includes(q) || (c.title || "").toLowerCase().includes(q)
);
if (statusFilter() === "active") r = r.filter((c) => c.is_active);
if (statusFilter() === "inactive") r = r.filter((c) => !c.is_active);
const sorted = [...r];
sorted.sort((a, b) => {
if (sortBy() === 'oldest') return String(a.id || '').localeCompare(String(b.id || ''));
if (sortBy() === 'code_asc') return String(a.code || '').localeCompare(String(b.code || ''));
if (sortBy() === 'code_desc') return String(b.code || '').localeCompare(String(a.code || ''));
return String(b.id || '').localeCompare(String(a.id || ''));
if (sortBy() === "oldest") return String(a.id || "").localeCompare(String(b.id || ""));
if (sortBy() === "code_asc") return String(a.code || "").localeCompare(String(b.code || ""));
if (sortBy() === "code_desc") return String(b.code || "").localeCompare(String(a.code || ""));
return String(b.id || "").localeCompare(String(a.id || ""));
});
r = sorted;
return r;
});
const exportCsv = () => {
const headers = ['Code', 'Title', 'Type', 'Value', 'Max Uses', 'Status'];
const headers = ["Code", "Title", "Type", "Value", "Max Uses", "Status"];
const rows = filteredCoupons().map((item) => [
item.code,
item.title || '',
item.title || "",
item.type,
item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : '—',
item.is_active ? 'Active' : 'Inactive',
item.type === "PERCENT" ? `${item.value}%` : `${item.value}`,
item.usage_limit != null ? String(item.usage_limit) : "—",
item.is_active ? "Active" : "Inactive",
]);
const csv = [headers, ...rows]
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = 'coupon-management.csv';
link.download = "coupon-management.csv";
link.click();
URL.revokeObjectURL(url);
};
const resetForm = () => {
setForm(defaultForm());
setFormError('');
setFormError("");
};
const startEdit = (coupon: Coupon) => {
setForm({
id: coupon.id,
code: coupon.code,
title: coupon.title || '',
title: coupon.title || "",
type: coupon.type,
value: coupon.value,
min_order_amount: coupon.min_order_amount || 0,
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : '',
max_uses: coupon.usage_limit != null ? String(coupon.usage_limit) : "",
role_keys: Array.isArray(coupon.role_keys) ? coupon.role_keys : [],
});
setActiveTab('create');
setActiveTab("create");
};
const toggleRole = (role: string) => {
@ -162,32 +171,33 @@ export default function CouponPage() {
e.preventDefault();
try {
setSaving(true);
setFormError('');
setFormError("");
const f = form();
const body: Record<string, unknown> = {
code: f.code.toUpperCase(),
title: f.title,
type: f.type,
value: Number(f.value),
discount_type: f.type,
discount_value: Number(f.value),
applies_to: f.applies_to,
min_order_amount: Number(f.min_order_amount),
role_keys: f.role_keys,
};
if (f.max_uses) body.max_uses = Number(f.max_uses);
const url = f.id ? `${API}/api/admin/coupons/${f.id}` : `${API}/api/admin/coupons`;
const method = f.id ? 'PATCH' : 'POST';
const method = f.id ? "PATCH" : "POST";
const res = await fetch(url, {
method,
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to save coupon');
if (!res.ok) throw new Error("Failed to save coupon");
resetForm();
await load();
setActiveTab('list');
setActiveTab("list");
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to save');
setFormError(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
@ -197,48 +207,59 @@ export default function CouponPage() {
try {
setToggling(coupon.id);
const res = await fetch(`${API}/api/admin/coupons/${coupon.id}`, {
method: 'PATCH',
method: "PATCH",
headers: authHeaders(),
credentials: 'include',
credentials: "include",
body: JSON.stringify({ is_active: !coupon.is_active }),
});
if (!res.ok) throw new Error('Failed to toggle');
if (!res.ok) throw new Error("Failed to toggle");
await load();
} catch {
// ignore
} finally {
setToggling('');
setToggling("");
}
};
return (
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Coupon Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Reusable coupon codes for package checkout</p>
</div>
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Coupon Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Reusable coupon codes for package checkout</p>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={activeTab() === 'list' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => setActiveTab('list')}
>
Coupons
</button>
<button
type="button"
class={activeTab() === 'create' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
onClick={() => { resetForm(); setActiveTab('create'); }}
>
{form().id ? 'Edit Coupon' : 'Create Coupon'}
</button>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
<button
type="button"
class={
activeTab() === "list"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setActiveTab("list")}
>
Coupons
</button>
<button
type="button"
class={
activeTab() === "create"
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => {
resetForm();
setActiveTab("create");
}}
>
{form().id ? "Edit Coupon" : "Create Coupon"}
</button>
</div>
<div>
<Show when={activeTab() === 'list'}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div>
<Show when={activeTab() === "list"}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;position:relative;z-index:20;margin-bottom:0">
<input
type="text"
@ -251,22 +272,51 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }}
onClick={() => {
setSortMenuOpen((v) => !v);
setFilterMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M7 4v13"/><path d="m3 13 4 4 4-4"/><path d="M17 20V7"/><path d="m21 11-4-4-4 4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M7 4v13" />
<path d="m3 13 4 4 4-4" />
<path d="M17 20V7" />
<path d="m21 11-4-4-4 4" />
</svg>
Sort
</button>
<Show when={sortMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'newest', label: 'Newest First' },
{ key: 'oldest', label: 'Oldest First' },
{ key: 'code_asc', label: 'Code A-Z' },
{ key: 'code_desc', label: 'Code Z-A' },
] as { key: 'newest' | 'oldest' | 'code_asc' | 'code_desc'; label: string }[]}>
<For
each={
[
{ key: "newest", label: "Newest First" },
{ key: "oldest", label: "Oldest First" },
{ key: "code_asc", label: "Code A-Z" },
{ key: "code_desc", label: "Code Z-A" },
] as {
key: "newest" | "oldest" | "code_asc" | "code_desc";
label: string;
}[]
}
>
{(item) => (
<button type="button" onClick={() => { setSortBy(item.key); setSortMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? '#FF5E13' : '#374151'};background:${sortBy() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setSortBy(item.key);
setSortMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -278,21 +328,44 @@ export default function CouponPage() {
<div style="position:relative;">
<button
type="button"
onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }}
onClick={() => {
setFilterMenuOpen((v) => !v);
setSortMenuOpen(false);
}}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 5h18M6 12h12M10 19h4" />
</svg>
Filters
</button>
<Show when={filterMenuOpen()}>
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:180px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
<For each={[
{ key: 'all', label: 'All Status' },
{ key: 'active', label: 'Active' },
{ key: 'inactive', label: 'Inactive' },
] as { key: string; label: string }[]}>
<For
each={
[
{ key: "all", label: "All Status" },
{ key: "active", label: "Active" },
{ key: "inactive", label: "Inactive" },
] as { key: string; label: string }[]
}
>
{(item) => (
<button type="button" onClick={() => { setStatusFilter(item.key); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? '#FF5E13' : '#374151'};background:${statusFilter() === item.key ? '#FFF1EB' : 'transparent'}`}>
<button
type="button"
onClick={() => {
setStatusFilter(item.key);
setFilterMenuOpen(false);
}}
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === item.key ? "#FF5E13" : "#374151"};background:${statusFilter() === item.key ? "#FFF1EB" : "transparent"}`}
>
{item.label}
</button>
)}
@ -301,13 +374,30 @@ export default function CouponPage() {
</Show>
</div>
<button type="button" onClick={exportCsv} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<button
type="button"
onClick={exportCsv}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;background:#0D0D2A;padding:0 12px;font-size:12px;font-weight:600;color:white;border:none;cursor:pointer"
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Export
</button>
</div>
<Show when={loadError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError()}
</div>
</Show>
<div class="table-card">
<div class="overflow-x-auto">
@ -325,35 +415,62 @@ export default function CouponPage() {
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No coupons found.</td></tr>
<tr>
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
No coupons found.
</td>
</tr>
</Show>
<Show when={!loading() && filteredCoupons().length > 0}>
<For each={filteredCoupons()}>
{(item) => (
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900" style="font-family:monospace">{item.code}</td>
<td class="text-slate-500">{item.title || '—'}</td>
<td class="font-semibold text-slate-900" style="font-family:monospace">
{item.code}
</td>
<td class="text-slate-500">{item.title || "—"}</td>
<td class="text-slate-500">{item.type}</td>
<td class="text-slate-500">{item.type === 'PERCENT' ? `${item.value}%` : `${item.value}`}</td>
<td class="text-slate-500">{item.usage_limit != null ? item.usage_limit : '—'}</td>
<td class="text-slate-500">
{item.type === "PERCENT" ? `${item.value}%` : `${item.value}`}
</td>
<td class="text-slate-500">
{item.usage_limit != null ? item.usage_limit : "—"}
</td>
<td>
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? '#FFD8C2' : '#D1D5DB'};background:${item.is_active ? '#FFF1EB' : '#F3F4F6'};color:${item.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px;flex-shrink:0`} />
{item.is_active ? 'Active' : 'Inactive'}
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${item.is_active ? "#FFD8C2" : "#D1D5DB"};background:${item.is_active ? "#FFF1EB" : "#F3F4F6"};color:${item.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${item.is_active ? "#FF5E13" : "#9CA3AF"};margin-right:5px;flex-shrink:0`}
/>
{item.is_active ? "Active" : "Inactive"}
</span>
</td>
<td>
<div class="flex items-center justify-end gap-1">
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEdit(item)}>Edit</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => startEdit(item)}
>
Edit
</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
disabled={toggling() === item.id}
onClick={() => handleToggle(item)}
>
{toggling() === item.id ? '...' : (item.is_active ? 'Disable' : 'Enable')}
{toggling() === item.id
? "..."
: item.is_active
? "Disable"
: "Enable"}
</button>
</div>
</td>
@ -370,112 +487,146 @@ export default function CouponPage() {
</div>
</Show>
</div>
</div>
</Show>
</div>
</Show>
<Show when={activeTab() === 'create'}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm" style="max-width:520px">
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">{form().id ? 'Edit Coupon' : 'Create Coupon'}</h2>
<Show when={formError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{formError()}</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<Show when={activeTab() === "create"}>
<section
class="rounded-xl border border-gray-200 bg-white shadow-sm"
style="max-width:520px"
>
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">
{form().id ? "Edit Coupon" : "Create Coupon"}
</h2>
<Show when={formError()}>
<div
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
style="margin-bottom:12px"
>
{formError()}
</div>
</Show>
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
<div class="field">
<label>Code</label>
<input
type="text"
value={form().code}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
required
placeholder="e.g. SAVE10"
style="text-transform:uppercase"
/>
</div>
<div class="field">
<label>Title</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. 10% off for companies"
/>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Code</label>
<label>Type</label>
<select
value={form().type}
onChange={(e) =>
setForm({ ...form(), type: e.currentTarget.value as "PERCENT" | "FIXED" })
}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input
type="text"
value={form().code}
onInput={(e) => setForm({ ...form(), code: e.currentTarget.value.toUpperCase() })}
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
placeholder="e.g. SAVE10"
style="text-transform:uppercase"
min="1"
/>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Min Order Amount ()</label>
<input
type="number"
value={form().min_order_amount}
onInput={(e) =>
setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })
}
min="0"
placeholder="0"
/>
</div>
<div class="field">
<label>Title</label>
<label>Max Uses (blank = unlimited)</label>
<input
type="text"
value={form().title}
onInput={(e) => setForm({ ...form(), title: e.currentTarget.value })}
required
placeholder="e.g. 10% off for companies"
type="number"
value={form().max_uses}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
min="1"
placeholder="Unlimited"
/>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Type</label>
<select
value={form().type}
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
>
<option value="PERCENT">Percent (%)</option>
<option value="FIXED">Fixed ()</option>
</select>
</div>
<div class="field">
<label>Value</label>
<input
type="number"
value={form().value}
onInput={(e) => setForm({ ...form(), value: Number(e.currentTarget.value) })}
required
min="1"
/>
</div>
<div class="field">
<label>Applies To</label>
<select
value={form().applies_to}
onChange={(e) =>
setForm({ ...form(), applies_to: e.currentTarget.value as "ALL" | "ROLE" })
}
>
<option value="ALL">All</option>
<option value="ROLE">Specific Roles</option>
</select>
</div>
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="field">
<label>Min Order Amount ()</label>
<input
type="number"
value={form().min_order_amount}
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
min="0"
placeholder="0"
/>
</div>
<div class="field">
<label>Max Uses (blank = unlimited)</label>
<input
type="number"
value={form().max_uses}
onInput={(e) => setForm({ ...form(), max_uses: e.currentTarget.value })}
min="1"
placeholder="Unlimited"
/>
</div>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">
Applicable Roles
</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}>
{(role) => {
const active = () => form().role_keys.includes(role);
return (
<button
type="button"
onClick={() => toggleRole(role)}
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? "#fdba74" : "#cbd5e1"};background:${active() ? "#fff7ed" : "#fff"};color:${active() ? "#c2410c" : "#475569"}`}
>
{role}
</button>
);
}}
</For>
</div>
<div>
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
<div style="display:flex;flex-wrap:wrap;gap:8px">
<For each={ROLE_OPTIONS}>
{(role) => {
const active = () => form().role_keys.includes(role);
return (
<button
type="button"
onClick={() => toggleRole(role)}
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? '#fdba74' : '#cbd5e1'};background:${active() ? '#fff7ed' : '#fff'};color:${active() ? '#c2410c' : '#475569'}`}
>
{role}
</button>
);
}}
</For>
</div>
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
</div>
<div class="actions">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? "Saving..." : form().id ? "Update Coupon" : "Save Coupon"}
</button>
<Show when={form().id}>
<button
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={resetForm}
>
Cancel Edit
</button>
<Show when={form().id}>
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={resetForm}>Cancel Edit</button>
</Show>
</div>
</form>
</section>
</Show>
</div>
</Show>
</div>
</form>
</section>
</Show>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1 +1,2 @@
export { default } from './designation';
import Designation from "./designation";
export default Designation;

File diff suppressed because it is too large Load diff

View file

@ -1,14 +1,16 @@
import { A, useNavigate } from '@solidjs/router';
import { createResource, createSignal, For, onMount, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createResource, createSignal, For, onMount, Show } from "solid-js";
const API = '';
const API = "";
type Role = { id: string; name: string };
type Dept = { id: string; name: string };
type Desig = { id: string; name: string };
function parseEmployeeCodeNumber(code: string): number | null {
const normalized = String(code || '').trim().toUpperCase();
const normalized = String(code || "")
.trim()
.toUpperCase();
if (!normalized) return null;
const explicit = normalized.match(/^EMP[-_]?0*(\d+)$/);
if (explicit) return Number(explicit[1]);
@ -18,59 +20,68 @@ function parseEmployeeCodeNumber(code: string): number | null {
}
function formatEmployeeCode(value: number): string {
return `EMP-${String(Math.max(1, value)).padStart(4, '0')}`;
return `EMP-${String(Math.max(1, value)).padStart(4, "0")}`;
}
async function fetchRoles(): Promise<Role[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL&per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.roles ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDepts(): Promise<Dept[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/departments?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.departments ?? []);
} catch { return []; }
} catch {
return [];
}
}
async function fetchDesigs(): Promise<Desig[]> {
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/designations?per_page=100`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error();
const data = await res.json();
return Array.isArray(data) ? data : (data.designations ?? []);
} catch { return []; }
} catch {
return [];
}
}
export default function CreateEmployeePage() {
@ -79,33 +90,40 @@ export default function CreateEmployeePage() {
const [depts] = createResource(fetchDepts);
const [desigs] = createResource(fetchDesigs);
const [fullName, setFullName] = createSignal('');
const [email, setEmail] = createSignal('');
const [employeeCode, setEmployeeCode] = createSignal('');
const [firstName, setFirstName] = createSignal("");
const [lastName, setLastName] = createSignal("");
const [email, setEmail] = createSignal("");
const [employeeCode, setEmployeeCode] = createSignal("");
const [createLoginCreds, setCreateLoginCreds] = createSignal(true);
const [loginPassword, setLoginPassword] = createSignal('');
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [deptId, setDeptId] = createSignal('');
const [desigId, setDesigId] = createSignal('');
const [loginPassword, setLoginPassword] = createSignal("");
const [confirmLoginPassword, setConfirmLoginPassword] = createSignal("");
const [showLoginPassword, setShowLoginPassword] = createSignal(false);
const [showConfirmPassword, setShowConfirmPassword] = createSignal(false);
const [roleId, setRoleId] = createSignal("");
const [deptId, setDeptId] = createSignal("");
const [desigId, setDesigId] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [generatingCode, setGeneratingCode] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const fetchNextEmployeeCode = async (): Promise<string> => {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
let page = 1;
let maxNum = 0;
while (page <= 100) {
const res = await fetch(`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`, {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
}).catch(() => null);
const res = await fetch(
`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
{
headers: {
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
}
).catch(() => null);
if (!res?.ok) break;
const payload = await res.json().catch(() => null);
const list: any[] = Array.isArray(payload)
@ -117,7 +135,7 @@ export default function CreateEmployeePage() {
: [];
if (!Array.isArray(list) || list.length === 0) break;
for (const item of list) {
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? '');
const raw = String(item?.employee_id ?? item?.employeeId ?? item?.employee_code ?? "");
const parsed = parseEmployeeCodeNumber(raw);
if (parsed && parsed > maxNum) maxNum = parsed;
}
@ -133,7 +151,7 @@ export default function CreateEmployeePage() {
try {
setEmployeeCode(await fetchNextEmployeeCode());
} catch {
setEmployeeCode('');
setEmployeeCode("");
} finally {
setGeneratingCode(false);
}
@ -142,238 +160,368 @@ export default function CreateEmployeePage() {
const handleSave = async (e: Event) => {
e.preventDefault();
if (!fullName().trim()) { setError('Full name is required'); return; }
if (!email().trim()) { setError('Email is required'); return; }
if (!roleId()) { setError('Internal role is required'); return; }
if (!deptId()) { setError('Department is required'); return; }
if (!desigId()) { setError('Designation is required'); return; }
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) { setError('Password must be at least 8 characters'); return; }
if (loginPassword().trim() !== confirmLoginPassword().trim()) { setError('Password and confirm password do not match'); return; }
if (!firstName().trim()) {
setError("First name is required");
return;
}
setError(''); setSaving(true);
if (!lastName().trim()) {
setError("Last name is required");
return;
}
if (!email().trim()) {
setError("Email is required");
return;
}
if (!roleId()) {
setError("Internal role is required");
return;
}
if (!deptId()) {
setError("Department is required");
return;
}
if (!desigId()) {
setError("Designation is required");
return;
}
if (createLoginCreds()) {
if (loginPassword().trim().length < 8) {
setError("Password must be at least 8 characters");
return;
}
if (loginPassword().trim() !== confirmLoginPassword().trim()) {
setError("Password and confirm password do not match");
return;
}
}
setError("");
setSaving(true);
try {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/employees/provision`, {
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
method: 'POST',
credentials: 'include',
method: "POST",
credentials: "include",
body: JSON.stringify({
email: email().trim(),
full_name: fullName().trim(),
role_id: roleId(),
department_id: deptId(),
designation_id: desigId(),
employee_code: employeeCode() || undefined,
first_name: firstName().trim(),
last_name: lastName().trim(),
role_code: roleId(),
department_id: deptId().trim(),
designation_id: desigId().trim(),
employee_code: employeeCode().trim() || undefined,
generate_login: createLoginCreds(),
password: createLoginCreds() ? loginPassword().trim() : undefined,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).error || (body as any).message || 'Failed to create employee');
throw new Error(
(body as any).error || (body as any).message || "Failed to create employee"
);
}
navigate('/admin/employees');
navigate("/admin/employees");
} catch (err: any) {
setError(err.message || 'Failed to create employee');
setError(err.message || "Failed to create employee");
} finally {
setSaving(false);
}
};
return (
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Internal Team</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Employee Management / Add Employee</p>
</div>
<A
href="/admin/employees"
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Back to Employees
</A>
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Internal Team
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Employee Management / Add Employee
</p>
</div>
{/* Form card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="border-b border-[#F3F4F6] px-6 py-4">
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Login credentials will be emailed to the employee automatically.</p>
</div>
<form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
{/* Full Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Full Name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="e.g. Arjun Sharma"
value={fullName()}
onInput={e => setFullName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Email */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Email Address <span class="text-red-500">*</span>
</label>
<input
type="email"
required
placeholder="e.g. arjun@nxtgauge.com"
value={email()}
onInput={e => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Employee ID */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Employee ID <span class="text-red-500">*</span>
</label>
<input
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input type="checkbox" checked={createLoginCreds()} onChange={e => setCreateLoginCreds(e.currentTarget.checked)} class="h-4 w-4 accent-[#FF5E13]" />
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">When enabled, a user account is created with the password below and then linked as employee.</p>
</div>
<Show when={createLoginCreds()}>
<>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Login Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={loginPassword()}
onInput={e => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Confirm Password <span class="text-red-500">*</span>
</label>
<input
type="password"
value={confirmLoginPassword()}
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
</>
</Show>
{/* Internal Role */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Internal Role <span class="text-red-500">*</span>
</label>
<select
value={roleId()}
onChange={e => setRoleId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select role</option>
<For each={roles() ?? []}>{r => <option value={r.id}>{r.name}</option>}</For>
</select>
</div>
{/* Department */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={deptId()}
onChange={e => setDeptId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select department</option>
<For each={depts() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
{/* Designation */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Designation <span class="text-red-500">*</span>
</label>
<select
value={desigId()}
onChange={e => setDesigId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select designation</option>
<For each={desigs() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
</div>
{/* Info note */}
<div class="rounded-lg border border-[#EFF6FF] bg-[#EFF6FF] px-4 py-3 text-[13px] text-[#2563EB]">
Login credentials will be auto-generated and sent to the employee's email address.
</div>
{/* Error */}
{error() && (
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">{error()}</div>
)}
{/* Footer */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] pt-5">
<A
href="/admin/employees"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="submit"
disabled={saving()}
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Add Employee'}
</button>
</div>
</form>
</div>
<A
href="/admin/employees"
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Back to Employees
</A>
</div>
{/* Form card */}
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
<div class="border-b border-[#F3F4F6] px-6 py-4">
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
<p class="mt-0.5 text-[13px] text-[#6B7280]">
Login credentials will be emailed to the employee automatically.
</p>
</div>
<form onSubmit={handleSave} class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
{/* First Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
First Name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="e.g. Arjun"
value={firstName()}
onInput={(e) => setFirstName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Last Name */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Last Name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="e.g. Sharma"
value={lastName()}
onInput={(e) => setLastName(e.currentTarget.value)}
maxlength="100"
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Email */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Email Address <span class="text-red-500">*</span>
</label>
<input
type="email"
required
placeholder="e.g. arjun@nxtgauge.com"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Employee ID */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Employee ID <span class="text-red-500">*</span>
</label>
<input
type="text"
readOnly
value={employeeCode()}
placeholder={generatingCode() ? "Generating..." : "Auto generated"}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
/>
</div>
{/* Login Credential Controls */}
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
<input
type="checkbox"
checked={createLoginCreds()}
onChange={(e) => setCreateLoginCreds(e.currentTarget.checked)}
class="h-4 w-4 accent-[#FF5E13]"
/>
Create login credentials if this email does not exist
</label>
<p class="mt-1 text-[12px] text-[#6B7280]">
When enabled, a user account is created with the password below and then linked as
employee.
</p>
</div>
<Show when={createLoginCreds()}>
<>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Login Password <span class="text-red-500">*</span>
</label>
<div style="position:relative">
<input
type={showLoginPassword() ? "text" : "password"}
value={loginPassword()}
onInput={(e) => setLoginPassword(e.currentTarget.value)}
placeholder="Minimum 8 characters"
class="w-full px-3 py-2.5 pr-10 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
<button
type="button"
onClick={() => setShowLoginPassword((v) => !v)}
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:#6B7280;padding:4px"
>
<Show
when={showLoginPassword()}
fallback={
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
</Show>
</button>
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Confirm Password <span class="text-red-500">*</span>
</label>
<div style="position:relative">
<input
type={showConfirmPassword() ? "text" : "password"}
value={confirmLoginPassword()}
onInput={(e) => setConfirmLoginPassword(e.currentTarget.value)}
placeholder="Repeat password"
class="w-full px-3 py-2.5 pr-10 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
/>
<button
type="button"
onClick={() => setShowConfirmPassword((v) => !v)}
style="position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:#6B7280;padding:4px"
>
<Show
when={showConfirmPassword()}
fallback={
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
</Show>
</button>
</div>
</div>
</>
</Show>
{/* Internal Role */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Internal Role <span class="text-red-500">*</span>
</label>
<select
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select role</option>
<For each={roles() ?? []}>{(r) => <option value={r.key}>{r.name}</option>}</For>
</select>
</div>
{/* Department */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={deptId()}
onChange={(e) => setDeptId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select department</option>
<For each={depts() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
{/* Designation */}
<div>
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
Designation <span class="text-red-500">*</span>
</label>
<select
value={desigId()}
onChange={(e) => setDesigId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
>
<option value="">Select designation</option>
<For each={desigs() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
</select>
</div>
</div>
{/* Info note */}
<div class="rounded-lg border border-[#EFF6FF] bg-[#EFF6FF] px-4 py-3 text-[13px] text-[#2563EB]">
Login credentials will be auto-generated and sent to the employee's email address.
</div>
{/* Error */}
{error() && (
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
)}
{/* Footer */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] pt-5">
<A
href="/admin/employees"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="submit"
disabled={saving()}
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
>
{saving() ? "Creating…" : "Add Employee"}
</button>
</div>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,14 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, createResource } from 'solid-js';
import { useSearchParams } from '@solidjs/router';
import {
For,
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
createResource,
} from "solid-js";
import { useSearchParams } from "@solidjs/router";
import {
BarChart3,
Building2,
@ -15,37 +24,38 @@ import {
Settings2,
TrendingUp,
Users,
} from 'lucide-solid';
} from "lucide-solid";
import {
ADMIN_DASHBOARD_WIDGETS,
type DashboardWidgetDefinition,
type DashboardWidgetSize,
} from '~/lib/admin/dashboard';
import type { RuntimeDashboardLayout } from '~/lib/runtime/types';
import { loadAdminDashboardLayout, saveAdminDashboardLayout } from '~/lib/runtime/storage';
} from "~/lib/admin/dashboard";
import type { RuntimeDashboardLayout } from "~/lib/runtime/types";
import { loadAdminDashboardLayout, saveAdminDashboardLayout } from "~/lib/runtime/storage";
const API = '';
const API = "";
async function fetchMetrics() {
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/dashboard/metrics`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error('Failed to load metrics');
if (!res.ok) throw new Error("Failed to load metrics");
return res.json();
}
type WidgetStateKind = 'live' | 'empty' | 'pending';
type WidgetType = 'summary' | 'analytics';
type SortMode = 'layout' | 'name' | 'status';
type FilterMode = 'all' | WidgetType;
type GridLayoutMode = '3x4' | '3x3';
type WidgetStateKind = "live" | "empty" | "pending";
type WidgetType = "summary" | "analytics";
type SortMode = "layout" | "name" | "status";
type FilterMode = "all" | WidgetType;
type GridLayoutMode = "3x4" | "3x3";
type WidgetMeta = {
state: WidgetStateKind;
@ -64,59 +74,63 @@ const DEFAULT_LAYOUT: RuntimeDashboardLayout = {
order: ADMIN_DASHBOARD_WIDGETS.slice()
.sort((a, b) => a.order - b.order)
.map((definition) => definition.widgetKey),
visibility: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultVisible])),
size: Object.fromEntries(ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultSize])),
visibility: Object.fromEntries(
ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultVisible])
),
size: Object.fromEntries(
ADMIN_DASHBOARD_WIDGETS.map((definition) => [definition.widgetKey, definition.defaultSize])
),
};
const WIDGET_META: Record<string, WidgetMeta> = {
kpi_total_users: {
state: 'live',
type: 'summary',
statusLabel: 'Live Data',
subtitle: 'Powered by USER_MANAGEMENT',
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by USER_MANAGEMENT",
},
kpi_active_companies: {
state: 'live',
type: 'summary',
statusLabel: 'Live Data',
subtitle: 'Powered by COMPANY_MANAGEMENT',
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by COMPANY_MANAGEMENT",
},
kpi_open_leads: {
state: 'live',
type: 'summary',
statusLabel: 'Live Data',
subtitle: 'Powered by REQUIREMENTS_MANAGEMENT',
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by REQUIREMENTS_MANAGEMENT",
},
kpi_pending_approvals: {
state: 'live',
type: 'summary',
statusLabel: 'Live Data',
subtitle: 'Powered by APPROVAL_MANAGEMENT',
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by APPROVAL_MANAGEMENT",
},
kpi_total_revenue: {
state: 'live',
type: 'summary',
statusLabel: 'Live Data',
subtitle: 'Powered by REVENUE_LEDGER',
state: "live",
type: "summary",
statusLabel: "Live Data",
subtitle: "Powered by REVENUE_LEDGER",
},
kpi_credits_purchased: {
state: 'empty',
type: 'summary',
statusLabel: 'No Data',
subtitle: 'Powered by CREDIT_MANAGEMENT',
emptyMessage: 'No credit activity available yet',
state: "empty",
type: "summary",
statusLabel: "No Data",
subtitle: "Powered by CREDIT_MANAGEMENT",
emptyMessage: "No credit activity available yet",
},
chart_leads_trend: {
state: 'live',
type: 'analytics',
statusLabel: 'Live Data',
subtitle: 'Weekly leads performance overview • Powered by REPORTS',
state: "live",
type: "analytics",
statusLabel: "Live Data",
subtitle: "Weekly leads performance overview • Powered by REPORTS",
},
chart_revenue_overview: {
state: 'live',
type: 'analytics',
statusLabel: 'Live Data',
subtitle: 'Weekly revenue overview • Powered by REVENUE_LEDGER',
state: "live",
type: "analytics",
statusLabel: "Live Data",
subtitle: "Weekly revenue overview • Powered by REVENUE_LEDGER",
},
};
@ -129,7 +143,7 @@ function sanitizeLayout(layout: RuntimeDashboardLayout | null | undefined): Runt
const normalizedOrder: string[] = [];
for (const key of incomingOrder) {
const normalizedKey = String(key || '');
const normalizedKey = String(key || "");
if (!knownKeys.has(normalizedKey) || seen.has(normalizedKey)) continue;
seen.add(normalizedKey);
normalizedOrder.push(normalizedKey);
@ -144,14 +158,16 @@ function sanitizeLayout(layout: RuntimeDashboardLayout | null | undefined): Runt
for (const definition of ADMIN_DASHBOARD_WIDGETS) {
const key = definition.widgetKey;
visibility[key] = typeof layout?.visibility?.[key] === 'boolean'
? Boolean(layout?.visibility?.[key])
: definition.defaultVisible;
visibility[key] =
typeof layout?.visibility?.[key] === "boolean"
? Boolean(layout?.visibility?.[key])
: definition.defaultVisible;
const rawSize = String(layout?.size?.[key] || '').toUpperCase();
size[key] = rawSize === 'S' || rawSize === 'M' || rawSize === 'L'
? (rawSize as DashboardWidgetSize)
: definition.defaultSize;
const rawSize = String(layout?.size?.[key] || "").toUpperCase();
size[key] =
rawSize === "S" || rawSize === "M" || rawSize === "L"
? (rawSize as DashboardWidgetSize)
: definition.defaultSize;
}
return {
@ -173,24 +189,24 @@ function reorderKeys(order: string[], draggedKey: string, targetKey: string): st
}
function iconForWidget(widgetKey: string) {
const cls = 'text-[#FA5014]';
if (widgetKey.includes('users')) return <Users size={22} class={cls} />;
if (widgetKey.includes('companies')) return <Building2 size={22} class={cls} />;
if (widgetKey.includes('leads')) return <TrendingUp size={22} class={cls} />;
if (widgetKey.includes('credits')) return <Coins size={22} class={cls} />;
if (widgetKey.includes('revenue')) return <BarChart3 size={22} class={cls} />;
if (widgetKey.includes('approvals')) return <CircleDashed size={22} class={cls} />;
const cls = "text-[#FA5014]";
if (widgetKey.includes("users")) return <Users size={22} class={cls} />;
if (widgetKey.includes("companies")) return <Building2 size={22} class={cls} />;
if (widgetKey.includes("leads")) return <TrendingUp size={22} class={cls} />;
if (widgetKey.includes("credits")) return <Coins size={22} class={cls} />;
if (widgetKey.includes("revenue")) return <BarChart3 size={22} class={cls} />;
if (widgetKey.includes("approvals")) return <CircleDashed size={22} class={cls} />;
return <LineChart size={22} class={cls} />;
}
function badgeClass(state: WidgetStateKind): string {
if (state === 'live') return 'border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]';
if (state === 'pending') return 'border-[#D1D5DB] bg-[#F3F4F6] text-[#6B7280]';
return 'border-[#E5E7EB] bg-white text-[#6B7280]';
if (state === "live") return "border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]";
if (state === "pending") return "border-[#D1D5DB] bg-[#F3F4F6] text-[#6B7280]";
return "border-[#E5E7EB] bg-white text-[#6B7280]";
}
function widgetSpan(mode: GridLayoutMode): string {
return mode === '3x3' ? 'xl:col-span-4' : 'xl:col-span-3';
return mode === "3x3" ? "xl:col-span-4" : "xl:col-span-3";
}
function EmptyPreview() {
@ -218,9 +234,9 @@ function PendingPreview() {
function LivePreview(props: { value?: string; trend?: string; trendUp?: boolean }) {
return (
<div class="flex h-full flex-col items-center justify-center">
<p class="text-[26px] font-bold leading-none text-[#111827]">{props.value || '0'}</p>
<p class="text-[26px] font-bold leading-none text-[#111827]">{props.value || "0"}</p>
<p class="mt-2 inline-flex items-center gap-1 text-xs font-semibold text-[#FA5014]">
{props.trendUp ? '↗' : '↘'} {props.trend || '0%'}
{props.trendUp ? "↗" : "↘"} {props.trend || "0%"}
</p>
</div>
);
@ -232,47 +248,49 @@ export default function AdminHomePage() {
const [settingsOpen, setSettingsOpen] = createSignal(false);
const [isHydrating, setIsHydrating] = createSignal(true);
const [isAutoSaving, setIsAutoSaving] = createSignal(false);
const [autoSaveNotice, setAutoSaveNotice] = createSignal('');
const [lastSavedSnapshot, setLastSavedSnapshot] = createSignal('');
const [autoSaveNotice, setAutoSaveNotice] = createSignal("");
const [lastSavedSnapshot, setLastSavedSnapshot] = createSignal("");
const [draggingKey, setDraggingKey] = createSignal<string | null>(null);
const [openMenuId, setOpenMenuId] = createSignal<string | null>(null);
const [search, setSearch] = createSignal('');
const [filterMode, setFilterMode] = createSignal<FilterMode>('all');
const [sortMode, setSortMode] = createSignal<SortMode>('layout');
const [gridLayout, setGridLayout] = createSignal<GridLayoutMode>('3x4');
const [search, setSearch] = createSignal("");
const [filterMode, setFilterMode] = createSignal<FilterMode>("all");
const [sortMode, setSortMode] = createSignal<SortMode>("layout");
const [gridLayout, setGridLayout] = createSignal<GridLayoutMode>("3x4");
const [metrics] = createResource(fetchMetrics);
const getWidgetState = (key: string) => {
if (key.startsWith('kpi_')) {
if (key.startsWith("kpi_")) {
const idMap: Record<string, string> = {
kpi_total_users: 'users',
kpi_active_companies: 'companies',
kpi_open_leads: 'leads',
kpi_pending_approvals: 'approvals',
kpi_total_revenue: 'revenue',
kpi_credits_purchased: 'credits',
kpi_total_users: "users",
kpi_active_companies: "companies",
kpi_open_leads: "leads",
kpi_pending_approvals: "approvals",
kpi_total_revenue: "revenue",
kpi_credits_purchased: "credits",
};
if (metrics.loading) return { state: 'pending', statusLabel: 'Loading...' };
if (metrics.loading) return { state: "pending", statusLabel: "Loading..." };
const m = metrics()?.kpis?.find((k: any) => k.id === idMap[key]);
if (m) return { state: 'live', statusLabel: 'Live Data', data: m };
return { state: 'empty', statusLabel: 'No Data' };
if (m) return { state: "live", statusLabel: "Live Data", data: m };
return { state: "empty", statusLabel: "No Data" };
}
if (metrics.loading) return { state: 'pending', statusLabel: 'Loading...' };
if (metrics.loading) return { state: "pending", statusLabel: "Loading..." };
const m = metrics();
if (key === 'chart_leads_trend') {
if (key === "chart_leads_trend") {
const data = m?.trend_series;
if (data && data.length > 0) return { state: 'live', statusLabel: 'Live Data', data: { trend_series: data } };
return { state: 'empty', statusLabel: 'No Data' };
if (data && data.length > 0)
return { state: "live", statusLabel: "Live Data", data: { trend_series: data } };
return { state: "empty", statusLabel: "No Data" };
}
if (key === 'chart_revenue_overview') {
if (key === "chart_revenue_overview") {
const data = m?.rev_series;
if (data && data.length > 0) return { state: 'live', statusLabel: 'Live Data', data: { rev_series: data } };
return { state: 'empty', statusLabel: 'No Data' };
if (data && data.length > 0)
return { state: "live", statusLabel: "Live Data", data: { rev_series: data } };
return { state: "empty", statusLabel: "No Data" };
}
const meta = WIDGET_META[key];
return { state: meta?.state || 'empty', statusLabel: meta?.statusLabel || 'No Data' };
return { state: meta?.state || "empty", statusLabel: meta?.statusLabel || "No Data" };
};
const orderedWidgets = createMemo(() => {
@ -285,26 +303,28 @@ export default function AdminHomePage() {
const query = search().trim().toLowerCase();
if (!query) return true;
const meta = WIDGET_META[definition.widgetKey];
return definition.title.toLowerCase().includes(query)
|| definition.moduleKey.toLowerCase().includes(query)
|| (meta?.subtitle || '').toLowerCase().includes(query);
return (
definition.title.toLowerCase().includes(query) ||
definition.moduleKey.toLowerCase().includes(query) ||
(meta?.subtitle || "").toLowerCase().includes(query)
);
})
.filter((definition) => {
const mode = filterMode();
if (mode === 'all') return true;
return (WIDGET_META[definition.widgetKey]?.type || 'summary') === mode;
if (mode === "all") return true;
return (WIDGET_META[definition.widgetKey]?.type || "summary") === mode;
});
const mode = sortMode();
if (mode === 'layout') return rows;
if (mode === "layout") return rows;
const next = rows.slice();
next.sort((a, b) => {
if (mode === 'name') return a.title.localeCompare(b.title);
if (mode === "name") return a.title.localeCompare(b.title);
const rank = (key: string) => {
const info = getWidgetState(key);
if (info.state === 'live') return 1;
if (info.state === 'empty') return 2;
if (info.state === "live") return 1;
if (info.state === "empty") return 2;
return 3;
};
return rank(a.widgetKey) - rank(b.widgetKey);
@ -331,16 +351,16 @@ export default function AdminHomePage() {
if (nextSnapshot === lastSavedSnapshot()) return;
setIsAutoSaving(true);
setAutoSaveNotice('');
setAutoSaveNotice("");
const timer = setTimeout(async () => {
const ok = await saveAdminDashboardLayout(nextLayout);
setIsAutoSaving(false);
if (ok) {
setLastSavedSnapshot(nextSnapshot);
setAutoSaveNotice('Layout saved automatically.');
setAutoSaveNotice("Layout saved automatically.");
} else {
setAutoSaveNotice('Auto-save failed. Please try again.');
setAutoSaveNotice("Auto-save failed. Please try again.");
}
}, 450);
@ -360,18 +380,18 @@ export default function AdminHomePage() {
const resetLayout = () => {
const normalized = sanitizeLayout(DEFAULT_LAYOUT);
setLayout(normalized);
setAutoSaveNotice('Layout reset to default.');
setAutoSaveNotice("Layout reset to default.");
};
const handleDragStart = (event: DragEvent, widgetKey: string) => {
event.dataTransfer?.setData('text/plain', widgetKey);
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
event.dataTransfer?.setData("text/plain", widgetKey);
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
setDraggingKey(widgetKey);
};
const handleDrop = (event: DragEvent, targetKey: string) => {
event.preventDefault();
const dragged = event.dataTransfer?.getData('text/plain') || draggingKey();
const dragged = event.dataTransfer?.getData("text/plain") || draggingKey();
if (!dragged) return;
setLayout((current) => ({
@ -382,224 +402,254 @@ export default function AdminHomePage() {
};
return (
<div class="w-full">
<Show when={Boolean(searchParams.denied)}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm" style="margin-bottom: 18px">
<p class="notice">You dont have access to {String(searchParams.denied || '').replace(/_/g, ' ')}.</p>
<div class="w-full">
<Show when={Boolean(searchParams.denied)}>
<div
class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm"
style="margin-bottom: 18px"
>
<p class="notice">
You dont have access to {String(searchParams.denied || "").replace(/_/g, " ")}.
</p>
</div>
</Show>
<div
class="rounded-2xl border border-[#E5E7EB] bg-white px-6 py-5 shadow-sm md:px-8"
style="margin-bottom: 28px"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-[22px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
<p class="mt-0.5 text-[13px] text-[#6B7280]">
Manage widget layout, visibility, sizing, and dashboard presentation
</p>
</div>
</Show>
<div class="rounded-2xl border border-[#E5E7EB] bg-white px-6 py-5 shadow-sm md:px-8" style="margin-bottom: 28px">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 class="text-[22px] font-bold leading-tight text-[#111827]">Dashboard Overview</h1>
<p class="mt-0.5 text-[13px] text-[#6B7280]">Manage widget layout, visibility, sizing, and dashboard presentation</p>
</div>
<div class="flex shrink-0">
<button
type="button"
class="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-5 text-[13px] font-semibold leading-none text-white shadow-sm"
onClick={() => {
const next = !settingsOpen();
setSettingsOpen(next);
if (!next) setOpenMenuId(null);
}}
>
<Settings2 size={14} class="text-[#FA5014]" />
{settingsOpen() ? 'Close Settings' : 'Customize Widgets'}
</button>
</div>
<div class="flex shrink-0">
<button
type="button"
class="inline-flex h-9 items-center gap-1.5 whitespace-nowrap rounded-xl bg-[#0D0D2A] px-5 text-[13px] font-semibold leading-none text-white shadow-sm"
onClick={() => {
const next = !settingsOpen();
setSettingsOpen(next);
if (!next) setOpenMenuId(null);
}}
>
<Settings2 size={14} class="text-[#FA5014]" />
{settingsOpen() ? "Close Settings" : "Customize Widgets"}
</button>
</div>
</div>
</div>
<Show when={settingsOpen()}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm md:p-7">
{/* Settings header */}
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
<p class="mt-1 text-[13px] text-[#6B7280]">Choose visible widgets and select a grid layout.</p>
</div>
<button
type="button"
class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-xs font-semibold text-[#111827] shadow-sm"
onClick={resetLayout}
>
<RotateCcw size={13} class="text-[#FA5014]" />
Reset Layout
</button>
<Show when={settingsOpen()}>
<div class="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm md:p-7">
{/* Settings header */}
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-[15px] font-semibold text-[#111827]">Widget Settings</h2>
<p class="mt-1 text-[13px] text-[#6B7280]">
Choose visible widgets and select a grid layout.
</p>
</div>
{/* Filter controls */}
<div class="mt-5 flex flex-wrap gap-3">
<div class="relative min-w-[200px] flex-1">
<Search size={14} class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]" style="z-index:1" />
<input
value={search()}
onInput={(event) => setSearch(event.currentTarget.value)}
placeholder="Search widgets"
class="h-10 w-full rounded-lg border border-[#E5E7EB] bg-white pl-10 pr-3 text-sm text-[#111827] outline-none focus:border-[#FA5014]"
/>
</div>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={filterMode()}
onChange={(event) => setFilterMode(event.currentTarget.value as FilterMode)}
>
<option value="all">All Widgets</option>
<option value="summary">Summary Only</option>
<option value="analytics">Analytical Only</option>
</select>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={sortMode()}
onChange={(event) => setSortMode(event.currentTarget.value as SortMode)}
>
<option value="layout">Layout Order</option>
<option value="name">Name</option>
<option value="status">Status</option>
</select>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={gridLayout()}
onChange={(event) => setGridLayout(event.currentTarget.value as GridLayoutMode)}
>
<option value="3x4">Grid 3 × 4</option>
<option value="3x3">Grid 3 × 3</option>
</select>
</div>
{/* Widget list */}
<div class="mt-5 grid gap-3 md:grid-cols-2">
<For each={ADMIN_DASHBOARD_WIDGETS}>
{(definition) => {
const visible = () => layout().visibility[definition.widgetKey] !== false;
const meta = WIDGET_META[definition.widgetKey];
const stateInfo = getWidgetState(definition.widgetKey);
const state = stateInfo.state;
return (
<div class="flex items-center justify-between gap-4 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-4">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 text-[#FA5014]">
{iconForWidget(definition.widgetKey)}
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
<p class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-[#9CA3AF]">{definition.moduleKey}</p>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<span class={`hidden rounded-full border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${badgeClass(state as WidgetStateKind)}`}>
{stateInfo.statusLabel}
</span>
<button
type="button"
class={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-semibold ${
visible()
? 'border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]'
: 'border-[#E5E7EB] bg-white text-[#6B7280]'
}`}
onClick={() => setWidgetVisibility(definition.widgetKey, !visible())}
>
{visible() ? <Eye size={12} /> : <EyeOff size={12} />}
{visible() ? 'Visible' : 'Hidden'}
</button>
</div>
</div>
);
}}
</For>
</div>
<Show when={isAutoSaving() || autoSaveNotice()}>
<p class="mt-4 text-xs text-[#6B7280]">{isAutoSaving() ? 'Saving layout...' : autoSaveNotice()}</p>
</Show>
<button
type="button"
class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-[#E5E7EB] bg-white px-3 text-xs font-semibold text-[#111827] shadow-sm"
onClick={resetLayout}
>
<RotateCcw size={13} class="text-[#FA5014]" />
Reset Layout
</button>
</div>
</Show>
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12" style="margin-top: 28px">
<For each={orderedWidgets()}>
{/* Filter controls */}
<div class="mt-5 flex flex-wrap gap-3">
<div class="relative min-w-[200px] flex-1">
<Search
size={14}
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[#FA5014]"
style="z-index:1"
/>
<input
value={search()}
onInput={(event) => setSearch(event.currentTarget.value)}
placeholder="Search widgets"
class="h-10 w-full rounded-lg border border-[#E5E7EB] bg-white pl-10 pr-3 text-sm text-[#111827] outline-none focus:border-[#FA5014]"
/>
</div>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={filterMode()}
onChange={(event) => setFilterMode(event.currentTarget.value as FilterMode)}
>
<option value="all">All Widgets</option>
<option value="summary">Summary Only</option>
<option value="analytics">Analytical Only</option>
</select>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={sortMode()}
onChange={(event) => setSortMode(event.currentTarget.value as SortMode)}
>
<option value="layout">Layout Order</option>
<option value="name">Name</option>
<option value="status">Status</option>
</select>
<select
class="h-10 rounded-lg border border-[#E5E7EB] bg-white px-3 text-sm text-[#111827]"
value={gridLayout()}
onChange={(event) => setGridLayout(event.currentTarget.value as GridLayoutMode)}
>
<option value="3x4">Grid 3 × 4</option>
<option value="3x3">Grid 3 × 3</option>
</select>
</div>
{/* Widget list */}
<div class="mt-5 grid gap-3 md:grid-cols-2">
<For each={ADMIN_DASHBOARD_WIDGETS}>
{(definition) => {
const visible = () => layout().visibility[definition.widgetKey] !== false;
const meta = WIDGET_META[definition.widgetKey];
const stateInfo = getWidgetState(definition.widgetKey);
const state = stateInfo.state;
const isOpenMenu = () => openMenuId() === definition.widgetKey;
return (
<section
draggable={settingsOpen()}
onDragStart={(event) => handleDragStart(event, definition.widgetKey)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handleDrop(event, definition.widgetKey)}
onDragEnd={() => setDraggingKey(null)}
class={`relative aspect-square min-h-[235px] rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm md:min-h-[260px] md:p-6 ${widgetSpan(gridLayout())} ${
state === 'pending' ? 'opacity-95' : ''
} ${draggingKey() === definition.widgetKey ? 'opacity-60' : ''}`}
>
<Show when={settingsOpen()}>
<>
<button
type="button"
class="absolute left-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white cursor-grab text-[#FA5014]"
aria-label="Drag to reorder"
>
<GripVertical size={14} class="text-[#FA5014]" />
</button>
<div class="absolute right-3 top-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white text-[#FA5014]"
onClick={() => setOpenMenuId(isOpenMenu() ? null : definition.widgetKey)}
>
<EllipsisVertical size={14} class="text-[#FA5014]" />
</button>
<Show when={isOpenMenu()}>
<div class="absolute right-0 top-10 z-20 w-40 rounded-lg border border-[#E5E7EB] bg-white p-1 shadow-md">
{['Edit Widget', 'Duplicate Widget', 'Hide Widget', 'Remove Widget'].map((item) => (
<button
type="button"
class="block w-full rounded-md px-2 py-2 text-left text-xs font-medium text-[#111827] hover:bg-[#F9FAFB]"
onClick={() => setOpenMenuId(null)}
>
{item}
</button>
))}
</div>
</Show>
</div>
</>
</Show>
<div class="flex h-full flex-col items-center justify-center px-4">
<div class="mb-3 text-[#FA5014]">
<div class="flex items-center justify-between gap-4 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-4">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 text-[#FA5014]">
{iconForWidget(definition.widgetKey)}
</div>
<h3 class="text-center text-[15px] font-semibold text-[#111827]">{definition.title}</h3>
<div class="mt-4 h-[116px] w-full max-w-[210px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<Show when={state === 'live'}>
<LivePreview value={stateInfo.data?.value} trend={stateInfo.data?.trend} trendUp={stateInfo.data?.trendUp} />
</Show>
<Show when={state === 'empty'}>
<EmptyPreview />
</Show>
<Show when={state === 'pending'}>
<PendingPreview />
</Show>
<div class="min-w-0">
<p class="text-sm font-semibold text-[#111827]">{definition.title}</p>
<p class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-[#9CA3AF]">
{definition.moduleKey}
</p>
</div>
<span class={`mt-4 inline-flex rounded-full border px-2 py-1 text-[11px] font-semibold ${badgeClass(state as WidgetStateKind)}`}>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
class={`hidden rounded-full border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${badgeClass(state as WidgetStateKind)}`}
>
{stateInfo.statusLabel}
</span>
<button
type="button"
class={`inline-flex h-8 items-center gap-1.5 rounded-md border px-3 text-xs font-semibold ${
visible()
? "border-[#FDBA8C] bg-[#FFF1EB] text-[#FA5014]"
: "border-[#E5E7EB] bg-white text-[#6B7280]"
}`}
onClick={() => setWidgetVisibility(definition.widgetKey, !visible())}
>
{visible() ? <Eye size={12} /> : <EyeOff size={12} />}
{visible() ? "Visible" : "Hidden"}
</button>
</div>
</section>
</div>
);
}}
</For>
</div>
<Show when={isAutoSaving() || autoSaveNotice()}>
<p class="mt-4 text-xs text-[#6B7280]">
{isAutoSaving() ? "Saving layout..." : autoSaveNotice()}
</p>
</Show>
</div>
</Show>
<div class="grid grid-cols-1 gap-5 xl:grid-cols-12" style="margin-top: 28px">
<For each={orderedWidgets()}>
{(definition) => {
const meta = WIDGET_META[definition.widgetKey];
const stateInfo = getWidgetState(definition.widgetKey);
const state = stateInfo.state;
const isOpenMenu = () => openMenuId() === definition.widgetKey;
return (
<section
draggable={settingsOpen()}
onDragStart={(event) => handleDragStart(event, definition.widgetKey)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => handleDrop(event, definition.widgetKey)}
onDragEnd={() => setDraggingKey(null)}
class={`relative aspect-square min-h-[235px] rounded-2xl border border-[#E5E7EB] bg-white p-5 shadow-sm md:min-h-[260px] md:p-6 ${widgetSpan(gridLayout())} ${
state === "pending" ? "opacity-95" : ""
} ${draggingKey() === definition.widgetKey ? "opacity-60" : ""}`}
>
<Show when={settingsOpen()}>
<>
<button
type="button"
class="absolute left-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white cursor-grab text-[#FA5014]"
aria-label="Drag to reorder"
>
<GripVertical size={14} class="text-[#FA5014]" />
</button>
<div class="absolute right-3 top-3">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#E5E7EB] bg-white text-[#FA5014]"
onClick={() => setOpenMenuId(isOpenMenu() ? null : definition.widgetKey)}
>
<EllipsisVertical size={14} class="text-[#FA5014]" />
</button>
<Show when={isOpenMenu()}>
<div class="absolute right-0 top-10 z-20 w-40 rounded-lg border border-[#E5E7EB] bg-white p-1 shadow-md">
{["Edit Widget", "Duplicate Widget", "Hide Widget", "Remove Widget"].map(
(item) => (
<button
type="button"
class="block w-full rounded-md px-2 py-2 text-left text-xs font-medium text-[#111827] hover:bg-[#F9FAFB]"
onClick={() => setOpenMenuId(null)}
>
{item}
</button>
)
)}
</div>
</Show>
</div>
</>
</Show>
<div class="flex h-full flex-col items-center justify-center px-4">
<div class="mb-3 text-[#FA5014]">{iconForWidget(definition.widgetKey)}</div>
<h3 class="text-center text-[15px] font-semibold text-[#111827]">
{definition.title}
</h3>
<div class="mt-4 h-[116px] w-full max-w-[210px] rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4">
<Show when={state === "live"}>
<LivePreview
value={stateInfo.data?.value}
trend={stateInfo.data?.trend}
trendUp={stateInfo.data?.trendUp}
/>
</Show>
<Show when={state === "empty"}>
<EmptyPreview />
</Show>
<Show when={state === "pending"}>
<PendingPreview />
</Show>
</div>
<span
class={`mt-4 inline-flex rounded-full border px-2 py-1 text-[11px] font-semibold ${badgeClass(state as WidgetStateKind)}`}
>
{stateInfo.statusLabel}
</span>
</div>
</section>
);
}}
</For>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { A, useParams } from '@solidjs/router';
import { createMemo, createResource, Show } from 'solid-js';
import { A, useParams } from "@solidjs/router";
import { createMemo, createResource, Show } from "solid-js";
const API = '';
const API = "";
type Job = {
id: string;
@ -28,7 +28,7 @@ type Job = {
async function fetchJob(id: string): Promise<Job | null> {
try {
const res = await fetch(`${API}/api/jobs/${id}`);
const res = await fetch(`${API}/api/admin/jobs/${id}`);
if (!res.ok) return null;
const data = await res.json();
return data.job || data;
@ -42,78 +42,99 @@ export default function JobDetailPage() {
const [job] = createResource(() => params.id, fetchJob);
const skills = createMemo(() => job()?.requiredSkills || job()?.required_skills || []);
const client = createMemo(() => job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || '—');
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || '—');
const client = createMemo(
() =>
job()?.clientName || job()?.client_name || job()?.companyName || job()?.company_name || "—"
);
const exp = createMemo(() => job()?.experienceLevel || job()?.experience_level || "—");
const rateMin = createMemo(() => job()?.hourlyRateMin ?? job()?.hourly_rate_min);
const rateMax = createMemo(() => job()?.hourlyRateMax ?? job()?.hourly_rate_max);
const duration = createMemo(() => job()?.durationDays ?? job()?.duration_days);
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Job Management</h1>
<p class="text-sm text-gray-500 mt-0.5">Review one live backend job in the same detail-first style as other admin modules.</p>
<p class="text-sm text-gray-500 mt-0.5">
Review one live backend job in the same detail-first style as other admin modules.
</p>
</div>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/jobs">Back to Jobs</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/jobs"
>
Back to Jobs
</A>
</div>
<div class="p-6 flex-1">
<Show when={job.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading job...</p></div>
</Show>
<Show when={!job.loading && !job()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Job not found.</p></div>
</Show>
<Show when={job()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || '—'}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || '—'}</p>
</div>
<div>
<p class="hint">Client</p>
<p style="margin:6px 0 0;color:#334155">{client()}</p>
</div>
<div>
<p class="hint">Experience</p>
<p style="margin:6px 0 0;color:#334155">{exp()}</p>
</div>
<div>
<p class="hint">Rate</p>
<p style="margin:6px 0 0;color:#334155">{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : '—'}</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || '—'}</p>
</div>
<div>
<p class="hint">Availability</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || '—'}</p>
</div>
<div>
<p class="hint">Duration</p>
<p style="margin:6px 0 0;color:#334155">{duration() != null ? `${duration()} days` : '—'}</p>
</div>
<div style="grid-column:1/-1">
<p class="hint">Required Skills</p>
<p style="margin:6px 0 0;color:#334155">{skills().length > 0 ? skills().join(', ') : '—'}</p>
</div>
<Show when={job.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading job...</p>
</div>
<div style="margin-top:18px">
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">{job()!.description || '—'}</p>
</Show>
<Show when={!job.loading && !job()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Job not found.</p>
</div>
</section>
</Show>
</div>
</Show>
<Show when={job()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p class="hint">Title</p>
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || "—"}</p>
</div>
<div>
<p class="hint">Status</p>
<p style="margin:6px 0 0;color:#334155">{job()!.status || "—"}</p>
</div>
<div>
<p class="hint">Client</p>
<p style="margin:6px 0 0;color:#334155">{client()}</p>
</div>
<div>
<p class="hint">Experience</p>
<p style="margin:6px 0 0;color:#334155">{exp()}</p>
</div>
<div>
<p class="hint">Rate</p>
<p style="margin:6px 0 0;color:#334155">
{rateMin() != null ? `${rateMin()} - ₹${rateMax() ?? rateMin()}` : "—"}
</p>
</div>
<div>
<p class="hint">Location</p>
<p style="margin:6px 0 0;color:#334155">{job()!.location || "—"}</p>
</div>
<div>
<p class="hint">Availability</p>
<p style="margin:6px 0 0;color:#334155">{job()!.availability || "—"}</p>
</div>
<div>
<p class="hint">Duration</p>
<p style="margin:6px 0 0;color:#334155">
{duration() != null ? `${duration()} days` : "—"}
</p>
</div>
<div style="grid-column:1/-1">
<p class="hint">Required Skills</p>
<p style="margin:6px 0 0;color:#334155">
{skills().length > 0 ? skills().join(", ") : "—"}
</p>
</div>
</div>
<div style="margin-top:18px">
<p class="hint">Description</p>
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">
{job()!.description || "—"}
</p>
</div>
</section>
</Show>
</div>
</div>
);
}

View file

@ -48,7 +48,16 @@ export default function JobsManagementPage() {
const load = async () => {
try {
const res = await fetch(`${API}/api/admin/companies/jobs`);
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch('/api/admin/companies/jobs', {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error('Fetch failed');
const data = await res.json();
const list = Array.isArray(data) ? data : (data.jobs || []);

View file

@ -1,12 +1,12 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createEffect, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createEffect, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
type KbArticle = {
@ -24,17 +24,17 @@ async function loadArticle(id: string): Promise<KbArticle | null> {
const token = getToken();
const res = await fetch(`${API}/api/admin/kb/articles/${id}`, {
headers: {
Accept: 'application/json',
Accept: "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
credentials: 'include',
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json();
return {
...data,
content: data?.content ?? data?.body ?? '',
body: data?.body ?? data?.content ?? '',
content: data?.content ?? data?.body ?? "",
body: data?.body ?? data?.content ?? "",
};
} catch {
return null;
@ -45,23 +45,23 @@ export default function KbArticleEditPage() {
const navigate = useNavigate();
const params = useParams();
const [article] = createResource(() => params.id, loadArticle);
const [title, setTitle] = createSignal('');
const [slug, setSlug] = createSignal('');
const [categoryId, setCategoryId] = createSignal('');
const [status, setStatus] = createSignal('DRAFT');
const [content, setContent] = createSignal('');
const [title, setTitle] = createSignal("");
const [slug, setSlug] = createSignal("");
const [categoryId, setCategoryId] = createSignal("");
const [status, setStatus] = createSignal("DRAFT");
const [content, setContent] = createSignal("");
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
const [loaded, setLoaded] = createSignal(false);
createEffect(() => {
const value = article();
if (!value || loaded()) return;
setTitle(value.title || '');
setSlug(value.slug || '');
setCategoryId(value.category_id || '');
setStatus(value.status || 'DRAFT');
setContent(value.content || value.body || '');
setTitle(value.title || "");
setSlug(value.slug || "");
setCategoryId(value.category_id || "");
setStatus(value.status || "DRAFT");
setContent(value.content || value.body || "");
setLoaded(true);
});
@ -69,95 +69,134 @@ export default function KbArticleEditPage() {
e.preventDefault();
try {
setSaving(true);
setError('');
setError("");
const res = await fetch(`${API}/api/admin/kb/articles/${params.id}`, {
method: 'PATCH',
method: "PATCH",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
title: title(),
slug: slug(),
category_id: categoryId() || null,
status: status(),
content: content(),
body: content(),
}),
});
if (!res.ok) throw new Error('Failed to save article');
if (!res.ok) throw new Error("Failed to save article");
navigate(`/admin/kb/articles/${params.id}`);
} catch (err: any) {
setError(err.message || 'Failed to save article');
setError(err.message || "Failed to save article");
} finally {
setSaving(false);
}
};
const inputCls = 'w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]';
const labelCls = 'mb-1.5 block text-sm font-medium text-gray-700';
const inputCls =
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]";
const labelCls = "mb-1.5 block text-sm font-medium text-gray-700";
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Edit KB Article</h1>
<p class="text-sm text-gray-500 mt-0.5">Update article metadata, status, and content.</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/kb/articles/${params.id}`}>Back to Detail</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/kb/articles">Back to Articles</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/kb/articles/${params.id}`}
>
Back to Detail
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/kb/articles"
>
Back to Articles
</A>
</div>
</div>
<div class="p-6 flex-1">
<Show when={article.loading}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Loading article</p></div>
</Show>
<Show when={!article.loading && !article()}>
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Article not found.</p></div>
</Show>
<Show when={article() && loaded()}>
<form class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" onSubmit={save}>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class={labelCls}>Title</label>
<input class={inputCls} value={title()} onInput={(e) => setTitle(e.currentTarget.value)} required />
</div>
<div>
<label class={labelCls}>Slug</label>
<input class={inputCls} value={slug()} onInput={(e) => setSlug(e.currentTarget.value)} />
</div>
<div>
<label class={labelCls}>Category ID</label>
<input class={inputCls} value={categoryId()} onInput={(e) => setCategoryId(e.currentTarget.value)} />
</div>
<div>
<label class={labelCls}>Status</label>
<select class={inputCls} value={status()} onChange={(e) => setStatus(e.currentTarget.value)}>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div class="sm:col-span-2">
<label class={labelCls}>Content</label>
<textarea rows="16" class={inputCls} value={content()} onInput={(e) => setContent(e.currentTarget.value)} />
</div>
<Show when={article.loading}>
<div class="table-card">
<p class="py-10 text-center text-sm text-slate-400">Loading article</p>
</div>
</Show>
<Show when={error()}>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</p>
</Show>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? 'Saving…' : 'Save Article'}
</button>
<Show when={!article.loading && !article()}>
<div class="table-card">
<p class="py-10 text-center text-sm text-slate-400">Article not found.</p>
</div>
</form>
</Show>
</div>
</Show>
<Show when={article() && loaded()}>
<form class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" onSubmit={save}>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class={labelCls}>Title</label>
<input
class={inputCls}
value={title()}
onInput={(e) => setTitle(e.currentTarget.value)}
required
/>
</div>
<div>
<label class={labelCls}>Slug</label>
<input
class={inputCls}
value={slug()}
onInput={(e) => setSlug(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Category ID</label>
<input
class={inputCls}
value={categoryId()}
onInput={(e) => setCategoryId(e.currentTarget.value)}
/>
</div>
<div>
<label class={labelCls}>Status</label>
<select
class={inputCls}
value={status()}
onChange={(e) => setStatus(e.currentTarget.value)}
>
<option value="DRAFT">DRAFT</option>
<option value="PUBLISHED">PUBLISHED</option>
</select>
</div>
<div class="sm:col-span-2">
<label class={labelCls}>Content</label>
<textarea
rows="16"
class={inputCls}
value={content()}
onInput={(e) => setContent(e.currentTarget.value)}
/>
</div>
</div>
<Show when={error()}>
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</p>
</Show>
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
<button class="btn-primary" type="submit" disabled={saving()}>
{saving() ? "Saving…" : "Save Article"}
</button>
</div>
</form>
</Show>
</div>
</div>
);
}

View file

@ -11,7 +11,16 @@ const ROLE_OPTIONS = [
async function loadLeads(): Promise<any[]> {
try {
const res = await fetch(`${API}/api/admin/leads`);
const accessToken = typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
const res = await fetch('/api/admin/leads', {
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
});
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
return Array.isArray(data) ? data : (data.leads || []);

View file

@ -1,18 +1,18 @@
import { createSignal, createMemo, onMount, Show, For } from 'solid-js';
import { createSignal, createMemo, onMount, Show, For } from "solid-js";
const API = '';
const API = "";
function getToken(): string {
return typeof sessionStorage !== 'undefined'
? sessionStorage.getItem('nxtgauge_admin_access_token') || ''
: '';
return typeof sessionStorage !== "undefined"
? sessionStorage.getItem("nxtgauge_admin_access_token") || ""
: "";
}
function authHeaders(): Record<string, string> {
const token = getToken();
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Accept: "application/json",
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
@ -20,71 +20,124 @@ function authHeaders(): Record<string, string> {
type Package = {
id: string;
name: string;
role: string;
tracecoin_amount: number;
price_inr: number;
bonus_percentage?: number;
description?: string;
package_type: string;
applicable_roles: string[];
tracecoins_amount: number;
price: number;
duration_days?: number;
valid_from?: string;
valid_until?: string;
is_promotional: boolean;
is_active: boolean;
features?: any;
created_at: string;
updated_at: string;
is_available?: boolean;
is_expired?: boolean;
};
const ROLES = [
'company', 'customer', 'job_seeker', 'photographer', 'video_editor',
'graphic_designer', 'social_media_manager', 'fitness_trainer',
'catering_services', 'makeup_artist', 'tutor', 'developer', 'ugc_content_creator',
const PACKAGE_TYPES = [
{ value: "TRACECOIN_BUNDLE", label: "Tracecoin Bundle" },
{ value: "CONTACT_VIEWS", label: "Contact Views (Company)" },
{ value: "JOB_POSTING", label: "Job Posting (Company)" },
{ value: "LEAD_REQUEST", label: "Lead Request (Professional)" },
{ value: "REQUIREMENT_SLOTS", label: "Requirement Slots (Customer)" },
];
type SortMode = 'name_asc' | 'name_desc' | 'price_asc' | 'price_desc' | 'coins_asc' | 'coins_desc';
const ALL_ROLES = [
"COMPANY",
"CUSTOMER",
"JOB_SEEKER",
"PHOTOGRAPHER",
"VIDEO_EDITOR",
"GRAPHIC_DESIGNER",
"SOCIAL_MEDIA_MANAGER",
"FITNESS_TRAINER",
"CATERING_SERVICE",
"MAKEUP_ARTIST",
"TUTOR",
"DEVELOPER",
"UGC_CONTENT_CREATOR",
];
const ROLE_LABELS: Record<string, string> = {
COMPANY: "Company",
CUSTOMER: "Customer",
JOB_SEEKER: "Job Seeker",
PHOTOGRAPHER: "Photographer",
VIDEO_EDITOR: "Video Editor",
GRAPHIC_DESIGNER: "Graphic Designer",
SOCIAL_MEDIA_MANAGER: "Social Media Manager",
FITNESS_TRAINER: "Fitness Trainer",
CATERING_SERVICE: "Catering Service",
MAKEUP_ARTIST: "Makeup Artist",
TUTOR: "Tutor",
DEVELOPER: "Developer",
UGC_CONTENT_CREATOR: "UGC Creator",
};
type SortMode = "name_asc" | "name_desc" | "price_asc" | "price_desc" | "coins_asc" | "coins_desc";
const SORT_LABELS: Record<SortMode, string> = {
name_asc: 'Name A→Z', name_desc: 'Name Z→A',
price_asc: 'Price ↑', price_desc: 'Price ↓',
coins_asc: 'TraceCoins ↑', coins_desc: 'TraceCoins ↓',
name_asc: "Name A→Z",
name_desc: "Name Z→A",
price_asc: "Price ↑",
price_desc: "Price ↓",
coins_asc: "TraceCoins ↑",
coins_desc: "TraceCoins ↓",
};
export default function PricingPage() {
const [rows, setRows] = createSignal<Package[]>([]);
const [loading, setLoading] = createSignal(true);
const [loadError, setLoadError] = createSignal('');
const [view, setView] = createSignal<'packages' | 'create'>('packages');
const [loadError, setLoadError] = createSignal("");
const [view, setView] = createSignal<"packages" | "create">("packages");
// Filters
const [search, setSearch] = createSignal('');
const [roleFilter, setRoleFilter] = createSignal('all');
const [statusFilter, setStatusFilter] = createSignal('all');
const [sortBy, setSortBy] = createSignal<SortMode>('name_asc');
const [search, setSearch] = createSignal("");
const [typeFilter, setTypeFilter] = createSignal("all");
const [statusFilter, setStatusFilter] = createSignal("all");
const [sortBy, setSortBy] = createSignal<SortMode>("name_asc");
const [sortOpen, setSortOpen] = createSignal(false);
// Inline edit
const [editingId, setEditingId] = createSignal('');
const [editName, setEditName] = createSignal('');
const [editTracecoins, setEditTracecoins] = createSignal('');
const [editPrice, setEditPrice] = createSignal('');
const [editingId, setEditingId] = createSignal("");
const [editName, setEditName] = createSignal("");
const [editTracecoins, setEditTracecoins] = createSignal("");
const [editPrice, setEditPrice] = createSignal("");
const [editSaving, setEditSaving] = createSignal(false);
const [editError, setEditError] = createSignal('');
const [togglingId, setTogglingId] = createSignal('');
const [editError, setEditError] = createSignal("");
const [togglingId, setTogglingId] = createSignal("");
// Create form
const [cName, setCName] = createSignal('');
const [cRole, setCRole] = createSignal(ROLES[0]);
const [cTracecoins, setCTracecoins] = createSignal('');
const [cPrice, setCPrice] = createSignal('');
const [cBonus, setCBonus] = createSignal('');
const [cName, setCName] = createSignal("");
const [cDescription, setCDescription] = createSignal("");
const [cType, setCType] = createSignal("TRACECOIN_BUNDLE");
const [cRoles, setCRoles] = createSignal<string[]>([]);
const [cTracecoins, setCTracecoins] = createSignal("");
const [cPrice, setCPrice] = createSignal("");
const [cDuration, setCDuration] = createSignal("");
const [cValidFrom, setCValidFrom] = createSignal("");
const [cValidUntil, setCValidUntil] = createSignal("");
const [cPromotional, setCPromotional] = createSignal(false);
const [cSaving, setCsaving] = createSignal(false);
const [cError, setCError] = createSignal('');
const [cError, setCError] = createSignal("");
const [roleDropdownOpen, setRoleDropdownOpen] = createSignal(false);
const load = async () => {
setLoading(true);
setLoadError('');
setLoadError("");
try {
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
const res = await fetch(`${API}/api/packages`, {
headers: authHeaders(),
credentials: 'include',
credentials: "include",
});
if (!res.ok) throw new Error(`Request failed (${res.status})`);
const data = await res.json();
setRows(Array.isArray(data) ? data : (data.packages ?? []));
setRows(Array.isArray(data) ? data : (data.data ?? []));
} catch (err: any) {
setLoadError(err.message || 'Could not load packages.');
setLoadError(err.message || "Could not load packages.");
setRows([]);
} finally {
setLoading(false);
@ -96,276 +149,631 @@ export default function PricingPage() {
const filteredRows = createMemo(() => {
let r = rows();
const q = search().toLowerCase();
if (q) r = r.filter((p) => p.name.toLowerCase().includes(q) || p.role.toLowerCase().includes(q));
if (roleFilter() !== 'all') r = r.filter((p) => p.role === roleFilter());
if (statusFilter() === 'active') r = r.filter((p) => p.is_active);
if (statusFilter() === 'inactive') r = r.filter((p) => !p.is_active);
if (q)
r = r.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.package_type.toLowerCase().includes(q) ||
p.applicable_roles.some((r) => r.toLowerCase().includes(q))
);
if (typeFilter() !== "all") r = r.filter((p) => p.package_type === typeFilter());
if (statusFilter() === "active") r = r.filter((p) => p.is_active);
if (statusFilter() === "inactive") r = r.filter((p) => !p.is_active);
const sorted = [...r];
const mode = sortBy();
sorted.sort((a, b) => {
if (mode === 'name_desc') return b.name.localeCompare(a.name);
if (mode === 'price_asc') return a.price_inr - b.price_inr;
if (mode === 'price_desc') return b.price_inr - a.price_inr;
if (mode === 'coins_asc') return a.tracecoin_amount - b.tracecoin_amount;
if (mode === 'coins_desc') return b.tracecoin_amount - a.tracecoin_amount;
if (mode === "name_desc") return b.name.localeCompare(a.name);
if (mode === "price_asc") return a.price - b.price;
if (mode === "price_desc") return b.price - a.price;
if (mode === "coins_asc") return a.tracecoins_amount - b.tracecoins_amount;
if (mode === "coins_desc") return b.tracecoins_amount - a.tracecoins_amount;
return a.name.localeCompare(b.name);
});
return sorted;
});
const startEdit = (pkg: Package) => {
setEditingId(pkg.id); setEditName(pkg.name);
setEditTracecoins(String(pkg.tracecoin_amount)); setEditPrice(String(pkg.price_inr));
setEditError('');
setEditingId(pkg.id);
setEditName(pkg.name);
setEditTracecoins(String(pkg.tracecoins_amount));
setEditPrice(String(pkg.price));
setEditError("");
};
const cancelEdit = () => {
setEditingId("");
setEditError("");
};
const cancelEdit = () => { setEditingId(''); setEditError(''); };
const saveEdit = async (id: string) => {
try {
setEditSaving(true); setEditError('');
const res = await fetch(`${API}/api/admin/tracecoin-packages/${id}`, {
method: 'PATCH', headers: authHeaders(), credentials: 'include',
body: JSON.stringify({ name: editName(), tracecoin_amount: Number(editTracecoins()), price_inr: Number(editPrice()) }),
setEditSaving(true);
setEditError("");
const res = await fetch(`${API}/api/packages/${id}`, {
method: "PATCH",
headers: authHeaders(),
credentials: "include",
body: JSON.stringify({
name: editName(),
tracecoins_amount: Number(editTracecoins()),
price: Number(editPrice()),
}),
});
if (!res.ok) throw new Error('Failed to save');
setEditingId(''); await load();
} catch (err: any) { setEditError(err.message || 'Failed to save'); }
finally { setEditSaving(false); }
if (!res.ok) throw new Error("Failed to save");
setEditingId("");
await load();
} catch (err: any) {
setEditError(err.message || "Failed to save");
} finally {
setEditSaving(false);
}
};
const toggleActive = async (pkg: Package) => {
try {
setTogglingId(pkg.id);
await fetch(`${API}/api/admin/tracecoin-packages/${pkg.id}`, {
method: 'PATCH', headers: authHeaders(), credentials: 'include',
await fetch(`${API}/api/packages/${pkg.id}`, {
method: "PATCH",
headers: authHeaders(),
credentials: "include",
body: JSON.stringify({ is_active: !pkg.is_active }),
});
await load();
} catch { /* ignore */ } finally { setTogglingId(''); }
} catch {
/* ignore */
} finally {
setTogglingId("");
}
};
const toggleRole = (role: string) => {
const current = cRoles();
if (current.includes(role)) {
setCRoles(current.filter((r) => r !== role));
} else {
setCRoles([...current, role]);
}
};
const handleCreate = async (e: Event) => {
e.preventDefault();
try {
setCsaving(true); setCError('');
setCsaving(true);
setCError("");
const body: Record<string, any> = {
name: cName(), role: cRole(),
tracecoin_amount: Number(cTracecoins()), price_inr: Number(cPrice()),
name: cName(),
description: cDescription() || undefined,
package_type: cType(),
applicable_roles: cRoles(),
tracecoins_amount: Number(cTracecoins()),
price: Number(cPrice()),
is_promotional: cPromotional(),
};
if (cBonus()) body.bonus_percentage = Number(cBonus());
const res = await fetch(`${API}/api/admin/tracecoin-packages`, {
method: 'POST', headers: authHeaders(), credentials: 'include',
if (cDuration()) body.duration_days = Number(cDuration());
if (cValidFrom()) body.valid_from = new Date(cValidFrom()).toISOString();
if (cValidUntil()) body.valid_until = new Date(cValidUntil()).toISOString();
const res = await fetch(`${API}/api/packages`, {
method: "POST",
headers: authHeaders(),
credentials: "include",
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed to create package');
setCName(''); setCRole(ROLES[0]); setCTracecoins(''); setCPrice(''); setCBonus('');
setView('packages'); await load();
} catch (err: any) { setCError(err.message || 'Failed to create'); }
finally { setCsaving(false); }
if (!res.ok) throw new Error("Failed to create package");
setCName("");
setCDescription("");
setCType("TRACECOIN_BUNDLE");
setCRoles([]);
setCTracecoins("");
setCPrice("");
setCDuration("");
setCValidFrom("");
setCValidUntil("");
setCPromotional(false);
setView("packages");
await load();
} catch (err: any) {
setCError(err.message || "Failed to create");
} finally {
setCsaving(false);
}
};
const formatDate = (dateStr?: string) => {
if (!dateStr) return "—";
return new Date(dateStr).toLocaleDateString("en-IN", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
const getTypeLabel = (type: string) => {
return PACKAGE_TYPES.find((t) => t.value === type)?.label || type;
};
return (
<div class="w-full space-y-6 pb-8">
<div style="margin-bottom:1.5rem">
<h1 class="text-[28px] font-bold leading-tight text-[#111827]">Pricing Management</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Create and manage TraceCoin packages</p>
<p class="mt-1 text-[14px] text-[#6B7280]">
Create and manage TraceCoin packages for all roles
</p>
</div>
{/* Tabs */}
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
{(['packages', 'create'] as const).map((t) => (
{(["packages", "create"] as const).map((t) => (
<button
type="button"
class={view() === t
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
: 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
class={
view() === t
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
}
onClick={() => setView(t)}
>
{t === 'packages' ? 'Packages' : 'Create Package'}
{t === "packages" ? "Packages" : "Create Package"}
</button>
))}
</div>
<div>
{/* ── Packages list ── */}
<Show when={view() === 'packages'}>
<Show when={view() === "packages"}>
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
<input
type="text"
placeholder="Search by name or role..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="height:34px;flex:1;min-width:220px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<select value={roleFilter()} onChange={(e) => setRoleFilter(e.currentTarget.value)} style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151">
<option value="all">All Roles</option>
<For each={ROLES}>{(r) => <option value={r}>{r}</option>}</For>
</select>
<select value={statusFilter()} onChange={(e) => setStatusFilter(e.currentTarget.value)} style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151">
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<div style="position:relative">
<div style="display:flex;align-items:center;gap:8px;padding:14px 20px;border-bottom:1px solid #F3F4F6;flex-wrap:wrap">
<input
type="text"
placeholder="Search by name or role..."
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
style="height:34px;flex:1;min-width:220px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
/>
<select
value={typeFilter()}
onChange={(e) => setTypeFilter(e.currentTarget.value)}
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
>
<option value="all">All Types</option>
<For each={PACKAGE_TYPES}>{(t) => <option value={t.value}>{t.label}</option>}</For>
</select>
<select
value={statusFilter()}
onChange={(e) => setStatusFilter(e.currentTarget.value)}
style="height:34px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;color:#374151"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
<div style="position:relative">
<button
type="button"
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
onClick={() => setSortOpen(!sortOpen())}
>
Sort: {SORT_LABELS[sortBy()]}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<Show when={sortOpen()}>
<div style="position:absolute;top:38px;right:0;background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:50;min-width:190px;padding:6px">
<For each={Object.entries(SORT_LABELS) as [SortMode, string][]}>
{([key, label]) => (
<button
type="button"
onClick={() => {
setSortBy(key);
setSortOpen(false);
}}
style={`display:block;width:100%;text-align:left;padding:8px 12px;font-size:13px;border-radius:8px;border:none;cursor:pointer;background:${sortBy() === key ? "#FFF1EB" : "transparent"};color:${sortBy() === key ? "#FF5E13" : "#374151"};font-weight:${sortBy() === key ? "600" : "400"}`}
>
{label}
</button>
)}
</For>
</div>
</Show>
</div>
<button
type="button"
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
onClick={() => setSortOpen(!sortOpen())}
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer"
onClick={load}
>
Sort: {SORT_LABELS[sortBy()]}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Refresh
</button>
<Show when={sortOpen()}>
<div style="position:absolute;top:38px;right:0;background:white;border:1px solid #e5e7eb;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.1);z-index:50;min-width:190px;padding:6px">
<For each={Object.entries(SORT_LABELS) as [SortMode, string][]}>
{([key, label]) => (
<button
type="button"
onClick={() => { setSortBy(key); setSortOpen(false); }}
style={`display:block;width:100%;text-align:left;padding:8px 12px;font-size:13px;border-radius:8px;border:none;cursor:pointer;background:${sortBy() === key ? '#FFF1EB' : 'transparent'};color:${sortBy() === key ? '#FF5E13' : '#374151'};font-weight:${sortBy() === key ? '600' : '400'}`}
>
{label}
</button>
)}
</For>
</div>
</Show>
</div>
<button type="button" style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #D1D5DB;background:#fff;padding:0 12px;font-size:12px;font-weight:600;color:#0f172a;cursor:pointer" onClick={load}>
Refresh
</button>
</div>
<Show when={loadError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{loadError()}</div>
</Show>
<Show when={loadError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{loadError()}
</div>
</Show>
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th><th>Role</th><th>TraceCoins</th><th>Price ()</th><th>Bonus</th><th>Status</th><th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
</Show>
<Show when={!loading() && filteredRows().length === 0}>
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No packages found.</td></tr>
</Show>
<Show when={!loading() && filteredRows().length > 0}>
<For each={filteredRows()}>
{(pkg) => (
<>
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">{pkg.name}</td>
<td><span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#f1f5f9;color:#475569">{pkg.role}</span></td>
<td class="text-slate-700 font-medium">{pkg.tracecoin_amount}</td>
<td class="text-slate-700">{(pkg.price_inr / 100).toFixed(2)}</td>
<td class="text-slate-500">{pkg.bonus_percentage != null ? `${pkg.bonus_percentage}%` : '—'}</td>
<td>
<span style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? '#FFD8C2' : '#D1D5DB'};background:${pkg.is_active ? '#FFF1EB' : '#F3F4F6'};color:${pkg.is_active ? '#FF5E13' : '#4B5563'};padding:2px 10px;font-size:12px;font-weight:500`}>
<span style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${pkg.is_active ? '#FF5E13' : '#9CA3AF'};margin-right:5px`} />
{pkg.is_active ? 'Active' : 'Inactive'}
</span>
</td>
<td>
<div class="flex items-center justify-end gap-1">
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={() => startEdit(pkg)}>Edit</button>
<button
class={pkg.is_active ? 'inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors' : 'btn-primary'}
disabled={togglingId() === pkg.id}
onClick={() => toggleActive(pkg)}
<div class="table-card">
<div class="overflow-x-auto">
<table class="data-table w-full text-sm">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Applicable Roles</th>
<th>TraceCoins</th>
<th>Price ()</th>
<th>Valid Period</th>
<th>Status</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<Show when={loading()}>
<tr>
<td colspan="8" style="text-align:center;padding:32px;color:#64748b">
Loading...
</td>
</tr>
</Show>
<Show when={!loading() && filteredRows().length === 0}>
<tr>
<td colspan="8" style="text-align:center;padding:32px;color:#94a3b8">
No packages found.
</td>
</tr>
</Show>
<Show when={!loading() && filteredRows().length > 0}>
<For each={filteredRows()}>
{(pkg) => (
<>
<tr class="hover:bg-slate-50">
<td class="font-semibold text-slate-900">
{pkg.name}
{pkg.is_promotional && (
<span style="margin-left:6px;font-size:10px;background:#FEF3C7;color:#D97706;padding:1px 6px;border-radius:4px">
PROMO
</span>
)}
</td>
<td>
<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600;background:#e0f2fe;color:#0369a1">
{getTypeLabel(pkg.package_type)}
</span>
</td>
<td>
<div style="display:flex;flex-wrap:wrap;gap:4px">
<For each={pkg.applicable_roles.slice(0, 3)}>
{(role) => (
<span style="display:inline-block;padding:1px 6px;border-radius:4px;font-size:10px;background:#f1f5f9;color:#475569">
{ROLE_LABELS[role] || role}
</span>
)}
</For>
{pkg.applicable_roles.length > 3 && (
<span style="font-size:10px;color:#64748b">
+{pkg.applicable_roles.length - 3}
</span>
)}
</div>
</td>
<td class="text-slate-700 font-medium">{pkg.tracecoins_amount}</td>
<td class="text-slate-700">{pkg.price.toLocaleString("en-IN")}</td>
<td style="font-size:12px;color:#64748b">
{pkg.valid_from || pkg.valid_until ? (
<>
{formatDate(pkg.valid_from)} - {formatDate(pkg.valid_until)}
</>
) : (
"Always"
)}
</td>
<td>
<span
style={`display:inline-flex;align-items:center;border-radius:9999px;border:1px solid ${pkg.is_active ? "#FFD8C2" : "#D1D5DB"};background:${pkg.is_active ? "#FFF1EB" : "#F3F4F6"};color:${pkg.is_active ? "#FF5E13" : "#4B5563"};padding:2px 10px;font-size:12px;font-weight:500`}
>
{togglingId() === pkg.id ? '...' : pkg.is_active ? 'Disable' : 'Enable'}
</button>
</div>
</td>
</tr>
<Show when={editingId() === pkg.id}>
<tr>
<td colspan="7" style="background:#f8fafc;padding:16px">
<Show when={editError()}>
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">{editError()}</div>
</Show>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Name</label>
<input type="text" value={editName()} onInput={(e) => setEditName(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px" />
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">TraceCoins</label>
<input type="number" value={editTracecoins()} onInput={(e) => setEditTracecoins(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" />
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">Price (paise)</label>
<input type="number" value={editPrice()} onInput={(e) => setEditPrice(e.currentTarget.value)} style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px" />
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" disabled={editSaving()} onClick={() => saveEdit(pkg.id)}>{editSaving() ? 'Saving...' : 'Save'}</button>
<button class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={cancelEdit}>Cancel</button>
</div>
<span
style={`display:inline-block;width:6px;height:6px;border-radius:50%;background:${pkg.is_active ? "#FF5E13" : "#9CA3AF"};margin-right:5px`}
/>
{pkg.is_active ? "Active" : "Inactive"}
</span>
</td>
<td>
<div class="flex items-center justify-end gap-1">
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={() => startEdit(pkg)}
>
Edit
</button>
<button
class={
pkg.is_active
? "inline-flex items-center rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-100 transition-colors"
: "btn-primary"
}
disabled={togglingId() === pkg.id}
onClick={() => toggleActive(pkg)}
>
{togglingId() === pkg.id
? "..."
: pkg.is_active
? "Disable"
: "Enable"}
</button>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
<Show when={!loading()}>
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
{filteredRows().length} of {rows().length} packages
<Show when={editingId() === pkg.id}>
<tr>
<td colspan="8" style="background:#f8fafc;padding:16px">
<Show when={editError()}>
<div class="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
{editError()}
</div>
</Show>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
Name
</label>
<input
type="text"
value={editName()}
onInput={(e) => setEditName(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:180px"
/>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
TraceCoins
</label>
<input
type="number"
value={editTracecoins()}
onInput={(e) => setEditTracecoins(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px">
Price ()
</label>
<input
type="number"
value={editPrice()}
onInput={(e) => setEditPrice(e.currentTarget.value)}
style="padding:7px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:13px;width:110px"
/>
</div>
<div style="display:flex;gap:8px">
<button
class="btn-primary"
disabled={editSaving()}
onClick={() => saveEdit(pkg.id)}
>
{editSaving() ? "Saving..." : "Save"}
</button>
<button
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
onClick={cancelEdit}
>
Cancel
</button>
</div>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</Show>
</tbody>
</table>
</div>
</Show>
</div>
<Show when={!loading()}>
<div style="padding:10px 16px;font-size:12px;color:#64748b;border-top:1px solid #f1f5f9">
{filteredRows().length} of {rows().length} packages
</div>
</Show>
</div>
</div>
</Show>
{/* ── Create Package ── */}
<Show when={view() === 'create'}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6" style="max-width:480px">
<Show when={view() === "create"}>
<section
class="rounded-xl border border-gray-200 bg-white shadow-sm p-6"
style="max-width:600px"
>
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">New Package</h2>
<Show when={cError()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{cError()}</div>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{cError()}
</div>
</Show>
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:14px">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Name</label>
<input type="text" value={cName()} onInput={(e) => setCName(e.currentTarget.value)} required placeholder="e.g. Starter Pack" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Package Name *
</label>
<input
type="text"
value={cName()}
onInput={(e) => setCName(e.currentTarget.value)}
required
placeholder="e.g. Christmas Special - 50 Tracecoins"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Role</label>
<select value={cRole()} onChange={(e) => setCRole(e.currentTarget.value)} required style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box">
<For each={ROLES}>{(r) => <option value={r}>{r}</option>}</For>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Description
</label>
<textarea
value={cDescription()}
onInput={(e) => setCDescription(e.currentTarget.value)}
placeholder="Optional description..."
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box;min-height:60px;resize:vertical"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Package Type *
</label>
<select
value={cType()}
onChange={(e) => setCType(e.currentTarget.value)}
required
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
>
<For each={PACKAGE_TYPES}>
{(t) => <option value={t.value}>{t.label}</option>}
</For>
</select>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">TraceCoins</label>
<input type="number" value={cTracecoins()} onInput={(e) => setCTracecoins(e.currentTarget.value)} required min="1" placeholder="e.g. 100" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
<div style="position:relative">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Applicable Roles *
</label>
<button
type="button"
onClick={() => setRoleDropdownOpen(!roleDropdownOpen())}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box;text-align:left;background:white;cursor:pointer"
>
{cRoles().length === 0
? "Select roles..."
: `${cRoles().length} role(s) selected`}
<span style="float:right"></span>
</button>
<Show when={roleDropdownOpen()}>
<div style="position:absolute;top:100%;left:0;right:0;background:white;border:1px solid #e2e8f0;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.1);z-index:20;max-height:200px;overflow-y:auto;margin-top:4px">
<For each={ALL_ROLES}>
{(role) => (
<label style="display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px">
<input
type="checkbox"
checked={cRoles().includes(role)}
onChange={() => toggleRole(role)}
/>
{ROLE_LABELS[role] || role}
</label>
)}
</For>
</div>
</Show>
<Show when={cRoles().length > 0}>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:8px">
<For each={cRoles()}>
{(role) => (
<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:#e0f2fe;color:#0369a1;border-radius:4px;font-size:12px">
{ROLE_LABELS[role] || role}
<button
type="button"
onClick={() => toggleRole(role)}
style="background:none;border:none;cursor:pointer;font-size:14px;padding:0;line-height:1"
>
×
</button>
</span>
)}
</For>
</div>
</Show>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Tracecoins Amount *
</label>
<input
type="number"
value={cTracecoins()}
onInput={(e) => setCTracecoins(e.currentTarget.value)}
required
min="1"
placeholder="e.g. 50"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Price () *
</label>
<input
type="number"
value={cPrice()}
onInput={(e) => setCPrice(e.currentTarget.value)}
required
min="1"
placeholder="e.g. 499"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Price INR (paise e.g. 49900 = 499)</label>
<input type="number" value={cPrice()} onInput={(e) => setCPrice(e.currentTarget.value)} required min="1" placeholder="e.g. 49900" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Duration (days)
</label>
<input
type="number"
value={cDuration()}
onInput={(e) => setCDuration(e.currentTarget.value)}
min="1"
placeholder="e.g. 30 (leave empty for unlimited)"
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Valid From
</label>
<input
type="date"
value={cValidFrom()}
onInput={(e) => setCValidFrom(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
Valid Until
</label>
<input
type="date"
value={cValidUntil()}
onInput={(e) => setCValidUntil(e.currentTarget.value)}
style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box"
/>
</div>
</div>
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Bonus % <span style="font-weight:400;color:#94a3b8">(optional)</span></label>
<input type="number" value={cBonus()} onInput={(e) => setCBonus(e.currentTarget.value)} min="0" placeholder="e.g. 10" style="width:100%;padding:8px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:14px;box-sizing:border-box" />
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input
type="checkbox"
checked={cPromotional()}
onChange={(e) => setCPromotional(e.currentTarget.checked)}
/>
<span style="font-size:13px;font-weight:600">Promotional Package</span>
</label>
<p style="font-size:12px;color:#64748b;margin-top:4px">
Promotional packages appear first in listings
</p>
</div>
<div>
<button class="btn-primary" type="submit" disabled={cSaving()}>{cSaving() ? 'Creating...' : 'Create Package'}</button>
<button class="btn-primary" type="submit" disabled={cSaving()}>
{cSaving() ? "Creating..." : "Create Package"}
</button>
</div>
</form>
</section>
</Show>
</div>
</div>
);

View file

@ -1,7 +1,7 @@
import { A, useNavigate } from '@solidjs/router';
import { createEffect, createMemo, createResource, createSignal, For, Show } from 'solid-js';
import { A, useNavigate } from "@solidjs/router";
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js";
const API = '';
const API = "";
type Permission = { key: string; module: string; action: string };
type Department = { id: string; name: string };
@ -10,9 +10,9 @@ function formatRoleKey(input: string): string {
return input
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_{2,}/g, '_');
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_{2,}/g, "_");
}
async function loadPermissions(): Promise<Permission[]> {
@ -39,42 +39,69 @@ async function loadDepartments(): Promise<Department[]> {
// Fallback static permissions matching backend MODULES
const STATIC_MODULES = [
'Department Management', 'Designation Management', 'Internal Role Management',
'Employee Management', 'External Role Management',
'Internal Dashboard Management', 'External Dashboard Management', 'Verification Management',
'Approval Management', 'Users Management', 'Company Management', 'Candidate Management',
'Customer Management', 'Photographer Management', 'Makeup Artist Management',
'Tutor Management', 'Developer Management', 'Fitness Trainer Management',
'Graphic Designer Management', 'Social Media Management', 'Video Editor Management',
'Catering Services Management', 'Jobs Management', 'Leads Management',
'Applications Management', 'Responses Management', 'Review Management',
'Pricing Management', 'Credit Management', 'Coupon Management', 'Discount Management',
'Tax Management', 'Order Management', 'Invoice Management', 'Ledger Management',
'Knowledge Base Management', 'Support Management', 'Report Management', 'Notifications',
"Department Management",
"Designation Management",
"Internal Role Management",
"Employee Management",
"External Role Management",
"Internal Dashboard Management",
"External Dashboard Management",
"Verification Management",
"Approval Management",
"Users Management",
"Company Management",
"Candidate Management",
"Customer Management",
"Photographer Management",
"Makeup Artist Management",
"Tutor Management",
"Developer Management",
"Fitness Trainer Management",
"Graphic Designer Management",
"Social Media Management",
"Video Editor Management",
"Catering Services Management",
"Jobs Management",
"Leads Management",
"Applications Management",
"Responses Management",
"Review Management",
"Pricing Management",
"Credit Management",
"Coupon Management",
"Discount Management",
"Tax Management",
"Order Management",
"Invoice Management",
"Ledger Management",
"Knowledge Base Management",
"Support Management",
"Report Management",
"Notifications",
];
const ACTIONS = ['View', 'Create', 'Update', 'Delete'] as const;
const ACTIONS = ["View", "Create", "Update", "Delete"] as const;
const STATIC_PERMISSIONS: Permission[] = STATIC_MODULES.flatMap((module) =>
ACTIONS.map((action) => ({
key: `${module.replace(/ /g, '_').toLowerCase()}:${action.toLowerCase()}`,
key: `${module.replace(/ /g, "_").toLowerCase()}:${action.toLowerCase()}`,
module,
action,
})),
}))
);
type SubTab = 'general' | 'module' | 'settings';
type SubTab = "general" | "module" | "settings";
export default function CreateInternalRolePage() {
const navigate = useNavigate();
const [permissions] = createResource(loadPermissions);
const [departments] = createResource(loadDepartments);
const [subTab, setSubTab] = createSignal<SubTab>('general');
const [subTab, setSubTab] = createSignal<SubTab>("general");
// General Information
const [roleName, setRoleName] = createSignal('');
const [roleCode, setRoleCode] = createSignal('');
const [departmentId, setDepartmentId] = createSignal('');
const [description, setDescription] = createSignal('');
const [roleName, setRoleName] = createSignal("");
const [roleCode, setRoleCode] = createSignal("");
const [departmentId, setDepartmentId] = createSignal("");
const [description, setDescription] = createSignal("");
// Module Access: selected permission keys
const [selectedKeys, setSelectedKeys] = createSignal<Set<string>>(new Set());
@ -85,7 +112,7 @@ export default function CreateInternalRolePage() {
const [canManage, setCanManage] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createEffect(() => {
setRoleCode(formatRoleKey(roleName()));
@ -138,317 +165,358 @@ export default function CreateInternalRolePage() {
const handleSave = async () => {
if (saving()) return;
if (!roleName().trim()) { setError('Role name is required'); setSubTab('general'); return; }
if (!roleName().trim()) {
setError("Role name is required");
setSubTab("general");
return;
}
const normalizedRoleCode = formatRoleKey(roleName());
if (!normalizedRoleCode) { setError('Role code is required'); setSubTab('general'); return; }
setError('');
if (!normalizedRoleCode) {
setError("Role code is required");
setSubTab("general");
return;
}
setError("");
try {
setSaving(true);
const accessToken = typeof sessionStorage !== 'undefined'
? (sessionStorage.getItem('nxtgauge_admin_access_token') || '').trim()
: '';
const accessToken =
typeof sessionStorage !== "undefined"
? (sessionStorage.getItem("nxtgauge_admin_access_token") || "").trim()
: "";
const res = await fetch(`${API}/api/admin/roles`, {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: 'include',
credentials: "include",
body: JSON.stringify({
key: normalizedRoleCode,
name: roleName().trim(),
audience: 'INTERNAL',
audience: "INTERNAL",
is_active: isActive(),
description: description().trim() || null,
department_id: departmentId() || null,
is_active: isActive(),
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
permission_keys: [...selectedKeys()],
}),
});
const raw = await res.text();
let message = '';
let message = "";
if (raw) {
try {
const parsed = JSON.parse(raw) as { message?: string; error?: string };
message = parsed?.message || parsed?.error || '';
const parsed = JSON.parse(raw) as { message?: string; error?: string; id?: string };
message = parsed?.message || parsed?.error || "";
} catch {
message = raw;
}
}
if (!res.ok) throw new Error(message || `Failed to create role (${res.status})`);
navigate('/admin/roles');
const roleData = JSON.parse(raw) as { id?: string };
if (roleData.id) {
await fetch(`${API}/api/admin/internal-roles`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
credentials: "include",
body: JSON.stringify({
role_id: roleData.id,
description: description().trim() || null,
department_id: departmentId() || null,
can_approve_requests: canApprove(),
can_manage_system_settings: canManage(),
}),
});
}
navigate("/admin/roles");
} catch (err: any) {
setError(String(err?.message || '').trim() || 'Failed to create role');
setError(String(err?.message || "").trim() || "Failed to create role");
} finally {
setSaving(false);
}
};
return (
<div class="w-full space-y-8 pb-8">
<div class="w-full space-y-8 pb-8">
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
Access Control
</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">
Create Internal Role
</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">
Dashboard / Internal Role Management / Create Role
</p>
</div>
<A
href="/admin/roles"
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Back to Roles
</A>
</div>
{/* Page header */}
<div class="flex items-end justify-between">
<div>
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">Access Control</p>
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Create Internal Role</h1>
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Internal Role Management / Create Role</p>
</div>
<A href="/admin/roles" class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors">
Back to Roles
</A>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Sub-tabs */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
{(
[
{ key: "general", label: "General Information" },
{ key: "module", label: "Module Access" },
{ key: "settings", label: "Role Settings" },
] as const
).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
}`}
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#FF5E13]" />
</Show>
</button>
))}
</div>
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
{/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
</div>
</Show>
{/* Sub-tabs */}
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
{(
[
{ key: 'general', label: 'General Information' },
{ key: 'module', label: 'Module Access' },
{ key: 'settings', label: 'Role Settings' },
] as const
).map((t) => (
<button
type="button"
onClick={() => setSubTab(t.key)}
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
subTab() === t.key ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]'
}`}
>
{t.label}
<Show when={subTab() === t.key}>
<span class="absolute inset-x-0 bottom-0 h-[2px] rounded-t-full bg-[#FF5E13]" />
</Show>
</button>
))}
</div>
{/* Error banner */}
<Show when={error()}>
<div class="mx-6 mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{error()}
{/* ── Tab: General Information ── */}
<Show when={subTab() === "general"}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)]"
/>
</div>
</Show>
{/* ── Tab: General Information ── */}
<Show when={subTab() === 'general'}>
<div class="p-6 space-y-5">
<div class="grid grid-cols-2 gap-5">
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Name <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Enter role name"
value={roleName()}
onInput={(e) => setRoleName(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)]"
/>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Code <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Auto-generated from role name"
value={roleCode()}
readOnly
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#F9FAFB] text-[#0D0D2A]"
/>
<p class="mt-1 text-[11px] text-[rgba(13,13,42,0.5)]">
This value is generated automatically (example: HR_MANAGER).
</p>
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#0D0D2A]"
>
<option value="">Select department</option>
<For each={departments() ?? []}>
{(dept) => <option value={dept.id}>{dept.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">Description</label>
<textarea
placeholder="Enter role description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
rows={4}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)] resize-none"
/>
</div>
</div>
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === 'module'}>
<div class="p-6">
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
Configure module access permissions for this role.
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Role Code <span class="text-red-500">*</span>
</label>
<input
type="text"
placeholder="Auto-generated from role name"
value={roleCode()}
readOnly
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg bg-[#F9FAFB] text-[#0D0D2A]"
/>
<p class="mt-1 text-[11px] text-[rgba(13,13,42,0.5)]">
This value is generated automatically (example: HR_MANAGER).
</p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">
<th class="px-5 py-3.5 text-left w-[40%]">Module</th>
<th class="px-4 py-3.5 text-center">View</th>
<th class="px-4 py-3.5 text-center">Create</th>
<th class="px-4 py-3.5 text-center">Update</th>
<th class="px-4 py-3.5 text-center">Delete</th>
<th class="px-4 py-3.5 text-center">
<button
type="button"
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
>
{allSelected() ? 'Deselect All' : 'Select All'}
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={permissions.loading}>
<tr>
<td colspan="6" class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]">
Loading modules
</div>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">
Department <span class="text-red-500">*</span>
</label>
<select
value={departmentId()}
onChange={(e) => setDepartmentId(e.currentTarget.value)}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#0D0D2A]"
>
<option value="">Select department</option>
<For each={departments() ?? []}>
{(dept) => <option value={dept.id}>{dept.name}</option>}
</For>
</select>
</div>
<div>
<label class="block text-[13px] font-medium text-[#0D0D2A] mb-1.5">Description</label>
<textarea
placeholder="Enter role description"
value={description()}
onInput={(e) => setDescription(e.currentTarget.value)}
rows={4}
class="w-full px-3 py-2.5 text-[13px] border border-[#e5e7eb] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#0D0D2A] placeholder-[rgba(13,13,42,0.3)] resize-none"
/>
</div>
</div>
</Show>
{/* ── Tab: Module Access ── */}
<Show when={subTab() === "module"}>
<div class="p-6">
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
Configure module access permissions for this role.
</p>
<div class="overflow-x-auto rounded-lg border border-[#e5e7eb]">
<table class="w-full">
<thead>
<tr class="border-b border-[#F3F4F6] bg-[#FAFAFA] text-[11px] font-semibold uppercase tracking-wider text-[#9CA3AF]">
<th class="px-5 py-3.5 text-left w-[40%]">Module</th>
<th class="px-4 py-3.5 text-center">View</th>
<th class="px-4 py-3.5 text-center">Create</th>
<th class="px-4 py-3.5 text-center">Update</th>
<th class="px-4 py-3.5 text-center">Delete</th>
<th class="px-4 py-3.5 text-center">
<button
type="button"
onClick={() => (allSelected() ? deselectAll() : selectAll())}
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
>
{allSelected() ? "Deselect All" : "Select All"}
</button>
</th>
</tr>
</thead>
<tbody class="divide-y divide-[#e5e7eb]">
<Show when={permissions.loading}>
<tr>
<td
colspan="6"
class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"
>
Loading modules
</td>
</tr>
</Show>
<For each={allModules()}>
{(module) => {
const perms = () => permsByModule()[module] ?? [];
const byAction = () => {
const m: Record<string, Permission> = {};
perms().forEach((p) => {
m[p.action] = p;
});
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#0D0D2A]">
{module}
</td>
{ACTIONS.map((action) => {
const p = () => byAction()[action];
return (
<td class="px-4 py-3.5 text-center">
<Show
when={p()}
fallback={<span class="text-[#d1d5db] text-xs"></span>}
>
<input
type="checkbox"
checked={selectedKeys().has(p()!.key)}
onChange={() => toggleKey(p()!.key)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</Show>
</td>
);
})}
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</td>
</tr>
</Show>
<For each={allModules()}>
{(module) => {
const perms = () => permsByModule()[module] ?? [];
const byAction = () => {
const m: Record<string, Permission> = {};
perms().forEach((p) => { m[p.action] = p; });
return m;
};
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
return (
<tr class="hover:bg-[#fafafa]">
<td class="px-5 py-3.5 text-[13px] font-medium text-[#0D0D2A]">
{module}
</td>
{ACTIONS.map((action) => {
const p = () => byAction()[action];
return (
<td class="px-4 py-3.5 text-center">
<Show
when={p()}
fallback={<span class="text-[#d1d5db] text-xs"></span>}
>
<input
type="checkbox"
checked={selectedKeys().has(p()!.key)}
onChange={() => toggleKey(p()!.key)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</Show>
</td>
);
})}
<td class="px-4 py-3.5 text-center">
<input
type="checkbox"
checked={rowAllSelected()}
onChange={() => toggleRow(module)}
class="h-4 w-4 accent-[#FF5E13] cursor-pointer"
/>
</td>
</tr>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === 'settings'}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
<p class="text-[13px] font-semibold text-[#0D0D2A] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
isActive()
? 'border-[#059669] bg-[#ECFDF5] text-[#059669]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
!isActive()
? 'border-[#6B7280] bg-[#F3F4F6] text-[#374151]'
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
}`}
>
Inactive
</button>
</div>
</div>
{/* Setting toggles */}
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer actions */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
<A
href="/admin/roles"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="button"
onClick={handleSave}
disabled={saving()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
>
{saving() ? 'Creating…' : 'Create Role'}
</button>
);
}}
</For>
</tbody>
</table>
</div>
</div>
</div>
</Show>
{/* ── Tab: Role Settings ── */}
<Show when={subTab() === "settings"}>
<div class="p-6 space-y-6">
{/* Status toggle */}
<div>
<p class="text-[13px] font-semibold text-[#0D0D2A] mb-3">Role Status</p>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => setIsActive(true)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
isActive()
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Active
</button>
<button
type="button"
onClick={() => setIsActive(false)}
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
!isActive()
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
}`}
>
Inactive
</button>
</div>
</div>
{/* Setting toggles */}
<div class="space-y-3">
<SettingToggle
label="Allow Role to Approve Requests"
description="Enable this role to approve various requests"
value={canApprove()}
onChange={setCanApprove}
/>
<SettingToggle
label="Allow Role to Manage System Settings"
description="Enable this role to manage system settings and configurations"
value={canManage()}
onChange={setCanManage}
/>
</div>
</div>
</Show>
{/* Footer actions */}
<div class="flex items-center justify-end gap-3 border-t border-[#F3F4F6] px-6 py-4">
<A
href="/admin/roles"
class="h-[40px] inline-flex items-center rounded-xl border border-[#E5E7EB] bg-white px-5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
>
Cancel
</A>
<button
type="button"
onClick={handleSave}
disabled={saving()}
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
>
{saving() ? "Creating…" : "Create Role"}
</button>
</div>
</div>
</div>
);
}
@ -471,12 +539,12 @@ function SettingToggle(props: {
aria-checked={props.value}
onClick={() => props.onChange(!props.value)}
class={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none ${
props.value ? 'bg-[#FF5E13]' : 'bg-[#d1d5db]'
props.value ? "bg-[#FF5E13]" : "bg-[#d1d5db]"
}`}
>
<span
class={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
props.value ? 'translate-x-6' : 'translate-x-1'
props.value ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import { A, useNavigate, useParams } from '@solidjs/router';
import { createMemo, createResource, createSignal, Show } from 'solid-js';
import { A, useNavigate, useParams } from "@solidjs/router";
import { createMemo, createResource, createSignal, Show } from "solid-js";
const API = '';
const API = "";
type Role = {
id: string;
@ -16,7 +16,7 @@ type User = {
roleId?: string;
role_id?: string;
role?: Role;
status?: 'ACTIVE' | 'INACTIVE' | 'PENDING';
status?: "ACTIVE" | "INACTIVE" | "PENDING";
createdAt?: string;
created_at?: string;
};
@ -26,7 +26,7 @@ async function fetchRoles(): Promise<Role[]> {
const res = await fetch(`${API}/api/admin/roles?audience=INTERNAL`);
if (!res.ok) return [];
const data = await res.json();
const rows = Array.isArray(data) ? data : (data.roles || []);
const rows = Array.isArray(data) ? data : data.roles || [];
return rows.map((r: any) => ({ id: r.id, name: r.name }));
} catch {
return [];
@ -52,132 +52,178 @@ export default function EditUserPage() {
const [user] = createResource(() => params.id, fetchUser);
const [roles] = createResource(fetchRoles);
const [name, setName] = createSignal('');
const [email, setEmail] = createSignal('');
const [roleId, setRoleId] = createSignal('');
const [status, setStatus] = createSignal<'ACTIVE' | 'INACTIVE' | 'PENDING'>('ACTIVE');
const [name, setName] = createSignal("");
const [email, setEmail] = createSignal("");
const [phone, setPhone] = createSignal("");
const [password, setPassword] = createSignal("");
const [roleId, setRoleId] = createSignal("");
const [status, setStatus] = createSignal<"ACTIVE" | "INACTIVE" | "PENDING">("ACTIVE");
const [submitting, setSubmitting] = createSignal(false);
const [error, setError] = createSignal('');
const [error, setError] = createSignal("");
createMemo(() => {
const u = user();
if (!u) return null;
setName(u.name || u.full_name || '');
setEmail(u.email || '');
setRoleId(u.roleId || u.role_id || u.role?.id || '');
setStatus((u.status || 'ACTIVE').toUpperCase() as 'ACTIVE' | 'INACTIVE' | 'PENDING');
setName(u.name || u.full_name || "");
setEmail(u.email || "");
setPhone(u.phone || "");
setStatus((u.status || "ACTIVE").toUpperCase() as "ACTIVE" | "INACTIVE" | "PENDING");
return null;
});
const save = async () => {
if (!name().trim() || !email().trim() || !roleId()) {
setError('Please fill in name, email, and role.');
setError("Please fill in name, email, and role.");
return;
}
try {
setSubmitting(true);
setError('');
setError("");
const body = {
name: name().trim(),
first_name: name().trim(),
email: email().trim(),
roleId: roleId(),
phone: phone().trim(),
password: password() || "",
role_id: roleId(),
status: status().toLowerCase(),
};
let res = await fetch(`${API}/api/admin/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
res = await fetch(`${API}/api/users/${params.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
throw new Error(payload.message || 'Failed to update user');
throw new Error(payload.message || "Failed to update user");
}
navigate('/admin/users');
navigate("/admin/users");
} catch (err: any) {
setError(err.message || 'Failed to update user');
setError(err.message || "Failed to update user");
} finally {
setSubmitting(false);
}
};
return (
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="flex flex-col -mx-6 -mt-6 min-h-full">
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-gray-900">Edit User</h1>
<p class="text-sm text-gray-500 mt-0.5">Update user profile, role assignment, and account status.</p>
<p class="text-sm text-gray-500 mt-0.5">
Update user profile, role assignment, and account status.
</p>
</div>
<div class="flex items-center gap-2">
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/users/details/${params.id}`}>View Details</A>
<A class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href="/admin/users">Back to Users</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href={`/admin/users/details/${params.id}`}
>
View Details
</A>
<A
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
href="/admin/users"
>
Back to Users
</A>
</div>
</div>
<div class="p-6">
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</div>
</Show>
<Show when={user.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading user...</p></div>
</Show>
<Show when={!user.loading && !user()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">User not found.</p></div>
</Show>
<Show when={user()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6 max-w-3xl">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
<input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input type="email" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)}>
<option value="">Select role</option>
<Show when={!roles.loading}>
{roles()?.map((r) => (
<option value={r.id}>{r.name}</option>
))}
</Show>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={status()} onChange={(e) => setStatus(e.currentTarget.value as 'ACTIVE' | 'INACTIVE' | 'PENDING')}>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
<Show when={error()}>
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error()}
</div>
</Show>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<button class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" type="button" onClick={() => navigate('/admin/users')}>Cancel</button>
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
{submitting() ? 'Saving…' : 'Save Changes'}
</button>
<Show when={user.loading}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">Loading user...</p>
</div>
</section>
</Show>
</div>
</Show>
<Show when={!user.loading && !user()}>
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
<p class="notice">User not found.</p>
</div>
</Show>
<Show when={user()}>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6 max-w-3xl">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
<input
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={roleId()}
onChange={(e) => setRoleId(e.currentTarget.value)}
>
<option value="">Select role</option>
<Show when={!roles.loading}>
{roles()?.map((r) => (
<option value={r.id}>{r.name}</option>
))}
</Show>
</select>
</div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
<select
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
value={status()}
onChange={(e) =>
setStatus(e.currentTarget.value as "ACTIVE" | "INACTIVE" | "PENDING")
}
>
<option value="ACTIVE">Active</option>
<option value="PENDING">Pending</option>
<option value="INACTIVE">Inactive</option>
</select>
</div>
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<button
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
type="button"
onClick={() => navigate("/admin/users")}
>
Cancel
</button>
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
{submitting() ? "Saving…" : "Save Changes"}
</button>
</div>
</section>
</Show>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
import { gatewayUrl, forwardAuth } from '~/lib/server/gateway';
export async function GET({ request }: { request: Request }) {
try {
const upstreamUrl = gatewayUrl('/api/me/notifications/unread-count');
const upstreamInit: RequestInit = {
method: 'GET',
headers: {
...forwardAuth(request),
Accept: 'application/json',
},
credentials: 'include',
};
const response = await fetch(upstreamUrl, upstreamInit);
const responseHeaders = new Headers();
response.headers.forEach((value, key) => {
if (!['server', 'transfer-encoding', 'connection'].includes(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
responseHeaders.set('Content-Type', 'application/json');
const responseBody = await response.text();
return new Response(responseBody, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error: any) {
return new Response(
JSON.stringify({ success: false, unread_count: 0 }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
}

View file

@ -76,7 +76,7 @@ export default function LoginPage() {
const body = JSON.stringify({ email: email().trim().toLowerCase(), password: password(), loginTarget: 'admin' });
const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'x-portal-target': 'admin' };
let payload: any = {}; let status = 500; let success = false;
const r = await fetch('/api/auth/login', { method: 'POST', headers, credentials: 'include', body });
const r = await fetch('/api/admin/auth/login', { method: 'POST', headers, credentials: 'include', body });
status = r.status; payload = await r.json().catch(() => ({}));
if (r.ok) { success = true; }
if (!success) {

View file

@ -0,0 +1,368 @@
import { expect, test } from "@playwright/test";
type RoleCase = {
roleKey: string;
applicantName: string;
type: "profile" | "job" | "requirement";
route?: string;
endpoint?: string;
viewLabel?: string;
};
const roleCases: RoleCase[] = [
{ roleKey: "COMPANY", applicantName: "Nxtgauge Labs", type: "job" },
{ roleKey: "CUSTOMER", applicantName: "Arun Customer", type: "requirement" },
{ roleKey: "JOB_SEEKER", applicantName: "Jaya Jobseeker", type: "profile" },
{ roleKey: "PHOTOGRAPHER", applicantName: "Priya Photographer", type: "profile", route: "/admin/photographer", endpoint: "/api/admin/photographers", viewLabel: "View Profile" },
{ roleKey: "MAKEUP_ARTIST", applicantName: "Maya Makeup", type: "profile", route: "/admin/makeup-artist", endpoint: "/api/admin/makeup-artists", viewLabel: "View Profile" },
{ roleKey: "TUTOR", applicantName: "Tejas Tutor", type: "profile", route: "/admin/tutors", endpoint: "/api/admin/tutors", viewLabel: "View Profile" },
{ roleKey: "DEVELOPER", applicantName: "Dev Developer", type: "profile", route: "/admin/developers", endpoint: "/api/admin/developers", viewLabel: "View Profile" },
{ roleKey: "VIDEO_EDITOR", applicantName: "Vani Video", type: "profile", route: "/admin/video-editors", endpoint: "/api/admin/video-editors", viewLabel: "View Profile" },
{ roleKey: "UGC_CONTENT_CREATOR", applicantName: "Uma UGC", type: "profile", route: "/admin/ugc-content-creator", viewLabel: "View Profile" },
{ roleKey: "GRAPHIC_DESIGNER", applicantName: "Gita Graphic", type: "profile", route: "/admin/graphic-designers", endpoint: "/api/admin/graphic-designers", viewLabel: "View Profile" },
{ roleKey: "SOCIAL_MEDIA_MANAGER", applicantName: "Soma Social", type: "profile", route: "/admin/social-media-managers", endpoint: "/api/admin/social-media-managers", viewLabel: "View Profile" },
{ roleKey: "FITNESS_TRAINER", applicantName: "Farah Fitness", type: "profile", route: "/admin/fitness-trainers", endpoint: "/api/admin/fitness-trainers", viewLabel: "View Profile" },
{ roleKey: "CATERING_SERVICES", applicantName: "Chetan Catering", type: "profile", route: "/admin/catering-services", endpoint: "/api/admin/catering-services", viewLabel: "View Profile" },
];
const now = new Date().toISOString();
function toTitle(value: string) {
return String(value || "")
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
test.describe("Verification to approval lifecycle across roles", () => {
test("all roles validate docs, request docs, approve, and appear in management with view actions", async ({ page }) => {
test.setTimeout(120000);
const verificationMap = new Map(
roleCases.map((item, idx) => {
const payload: any = {
first_name: item.applicantName.split(" ")[0] || "User",
last_name: item.applicantName.split(" ").slice(1).join(" ") || "",
company_name: item.roleKey === "COMPANY" ? "Nxtgauge Labs Pvt Ltd" : undefined,
city: "Chennai",
area: "Anna Nagar",
role_key: item.roleKey,
documents: [
{
id: `${item.roleKey.toLowerCase()}-doc-image`,
title: `${toTitle(item.roleKey)} ID Proof`,
type: "IMAGE",
url: "/nxtgauge-logo.png",
status: "SUBMITTED",
},
{
id: `${item.roleKey.toLowerCase()}-doc-pdf`,
title: `${toTitle(item.roleKey)} Address Proof`,
type: "PDF",
url: "/nxtgauge-icon.png",
status: "SUBMITTED",
},
],
};
if (item.type === "job") payload.job_id = "job-001";
if (item.type === "requirement") payload.requirement_id = "req-001";
return [
item.roleKey,
{
id: `ver-${idx + 1}`,
user_id: `user-${idx + 1}`,
user_name: item.applicantName,
role_key: item.roleKey,
type: item.type,
case_type: item.type,
status: "PENDING",
created_at: now,
updated_at: now,
payload,
},
];
})
);
const finalApprovedRoles = new Set<string>();
let jobFinalApproved = false;
let requirementFinalApproved = false;
const statusForRole = (roleKey: string) => {
if (roleKey === "COMPANY") return jobFinalApproved ? "ACTIVE" : "PENDING";
if (roleKey === "CUSTOMER") return requirementFinalApproved ? "ACTIVE" : "PENDING";
return finalApprovedRoles.has(roleKey) ? "ACTIVE" : "PENDING";
};
const asProfessionRow = (roleKey: string, applicantName: string) => {
const parts = applicantName.split(" ");
return {
id: `pro-${roleKey.toLowerCase()}`,
first_name: parts[0] || applicantName,
last_name: parts.slice(1).join(" ") || "User",
email: `${roleKey.toLowerCase()}@example.test`,
phone: "9999999999",
status: statusForRole(roleKey),
created_at: now,
};
};
await page.route("**/api/admin/**", async (route) => {
const req = route.request();
const url = new URL(req.url());
const path = url.pathname;
const method = req.method();
if (path === "/api/admin/verifications" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: Array.from(verificationMap.values()) }),
});
return;
}
if (path.startsWith("/api/admin/verifications/") && method === "GET") {
const id = path.split("/").pop() || "";
const row = Array.from(verificationMap.values()).find((item) => item.id === id);
await route.fulfill({
status: row ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(row || { error: "Not found" }),
});
return;
}
if (path.startsWith("/api/admin/verifications/") && method === "POST") {
const parts = path.split("/");
const id = parts[4] || "";
const action = parts[5] || "";
const row = Array.from(verificationMap.values()).find((item) => item.id === id);
if (!row) {
await route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ error: "Not found" }) });
return;
}
if (action === "approve") row.status = "APPROVED";
if (action === "reject") row.status = "REJECTED";
if (action === "request-documents") row.status = "DOCUMENTS_REQUESTED";
if (action === "request-revision") row.status = "REVISION_REQUESTED";
row.updated_at = new Date().toISOString();
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path.startsWith("/api/admin/approvals/jobs/") && method === "POST") {
if (path.endsWith("/approve")) jobFinalApproved = true;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path.startsWith("/api/admin/approvals/requirements/") && method === "POST") {
if (path.endsWith("/approve")) requirementFinalApproved = true;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path.startsWith("/api/admin/approvals/profiles/") && method === "POST") {
const verificationId = path.split("/")[5] || "";
const row = Array.from(verificationMap.values()).find((item) => item.id === verificationId);
if (path.endsWith("/approve") && row) finalApprovedRoles.add(row.role_key);
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }) });
return;
}
if (path === "/api/admin/companies" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "cmp-001",
company_name: "Nxtgauge Labs Pvt Ltd",
registration_number: "TN-REG-0021",
industry: "Software",
city: "Chennai",
status: jobFinalApproved ? "APPROVED" : "PENDING",
created_at: now,
updated_at: now,
job_postings_count: 3,
total_hires: 1,
},
]),
});
return;
}
if (path === "/api/admin/companies/jobs" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "job-001",
title: "Senior Frontend Engineer",
company_name: "Nxtgauge Labs Pvt Ltd",
location: "Chennai",
salary_min: 1200000,
salary_max: 1800000,
status: jobFinalApproved ? "ACTIVE" : "PENDING_APPROVAL",
created_at: now,
updated_at: now,
},
]),
});
return;
}
if (path === "/api/admin/leads" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "req-001",
title: "Wedding Photography Requirement",
profession: "photographer",
budget_range: "INR 75,000",
location: "Chennai",
status: requirementFinalApproved ? "ACTIVE" : "PENDING",
description: "Need full-day candid and cinematic coverage",
created_at: now,
updated_at: now,
},
]),
});
return;
}
if (path === "/api/admin/users" && method === "GET") {
const requestedRole = String(url.searchParams.get("role") || "").toUpperCase();
const users = roleCases.map((item, idx) => {
const status = statusForRole(item.roleKey);
const names = item.applicantName.split(" ");
return {
id: `user-${idx + 1}`,
first_name: names[0] || "User",
last_name: names.slice(1).join(" ") || "",
full_name: item.applicantName,
email: `${item.roleKey.toLowerCase()}@example.test`,
status,
roles: status === "ACTIVE" ? [item.roleKey] : [],
created_at: now,
updated_at: now,
};
});
const payload = requestedRole ? users.filter((u) => u.roles.includes(requestedRole)) : users;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
return;
}
const professionEndpointMap: Record<string, string> = {
"/api/admin/photographers": "PHOTOGRAPHER",
"/api/admin/makeup-artists": "MAKEUP_ARTIST",
"/api/admin/tutors": "TUTOR",
"/api/admin/developers": "DEVELOPER",
"/api/admin/video-editors": "VIDEO_EDITOR",
"/api/admin/graphic-designers": "GRAPHIC_DESIGNER",
"/api/admin/social-media-managers": "SOCIAL_MEDIA_MANAGER",
"/api/admin/fitness-trainers": "FITNESS_TRAINER",
"/api/admin/catering-services": "CATERING_SERVICES",
};
if (method === "GET" && professionEndpointMap[path]) {
const roleKey = professionEndpointMap[path];
const roleCase = roleCases.find((item) => item.roleKey === roleKey)!;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([asProfessionRow(roleKey, roleCase.applicantName)]),
});
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.goto("/admin/verification?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Verification Management" })).toBeVisible();
for (const item of roleCases) {
await expect(page.getByText(toTitle(item.roleKey), { exact: false }).first()).toBeVisible();
}
const firstRole = roleCases[0];
const firstRow = page.locator("tr", { hasText: firstRole.applicantName }).first();
await firstRow.getByRole("button", { name: "View" }).click();
await expect(page.getByText("Submitted Documents")).toBeVisible();
await page.getByRole("button", { name: "View" }).nth(1).click();
await expect(page.getByRole("button", { name: "Close" })).toBeVisible();
await page.getByRole("button", { name: "Close" }).click();
const requestCheckbox = page.locator('input[type="checkbox"]').nth(1);
await requestCheckbox.check();
await page.getByRole("button", { name: "Request Selected Documents" }).click();
await expect(page.getByText(/Document request sent/i)).toBeVisible();
await page.getByRole("button", { name: "Approve" }).first().click();
await expect(page.getByText("Successfully verified and sent to Approval Management.")).toBeVisible();
await page.getByRole("button", { name: "Back to List" }).click();
for (const item of roleCases.slice(1)) {
const row = page.locator("tr", { hasText: item.applicantName }).first();
await row.getByRole("button", { name: "View" }).click();
await page.getByRole("button", { name: "Approve" }).first().click();
await page.getByRole("button", { name: "Back to List" }).click();
}
await page.goto("/admin/approval?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Approval Management" })).toBeVisible();
for (const item of roleCases) {
const row = page.locator("tr", { hasText: item.applicantName }).first();
await expect(row).toBeVisible();
}
const firstApprovalRow = page.locator("tbody tr").first();
await firstApprovalRow.locator("button").first().click();
await page.getByRole("button", { name: /^Approve$/ }).first().click();
jobFinalApproved = true;
requirementFinalApproved = true;
for (const item of roleCases) {
if (item.type === "profile") finalApprovedRoles.add(item.roleKey);
}
await page.goto("/admin/jobs?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Jobs Management" })).toBeVisible();
await expect(page.locator("tr", { hasText: "Senior Frontend Engineer" })).toContainText("Active");
await page.goto("/admin/leads?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Leads Management" })).toBeVisible();
await expect(page.locator("tr", { hasText: "Wedding Photography Requirement" })).toContainText("ACTIVE");
await expect(page.getByRole("link", { name: "View" }).first()).toBeVisible();
await page.goto("/admin/company?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Company Management" })).toBeVisible();
const companyRow = page.locator("tr", { hasText: "Nxtgauge Labs Pvt Ltd" }).first();
await expect(companyRow).toContainText("Active");
await companyRow.locator("button").first().click();
await companyRow.getByRole("button", { name: "View Company" }).click();
await expect(page.getByRole("button", { name: "Back to List" })).toBeVisible();
await page.goto("/admin/users?_preview=1", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Users Management" })).toBeVisible();
for (const item of roleCases) {
await expect(page.getByText(toTitle(item.roleKey), { exact: false }).first()).toBeVisible();
}
for (const item of roleCases.filter((row) => row.route)) {
await page.goto(`${item.route}?_preview=1`, { waitUntil: "domcontentloaded" });
await expect(page.getByText(item.applicantName.split(" ")[0], { exact: false }).first()).toBeVisible();
const tableRow = page.locator("tr", { hasText: item.applicantName.split(" ")[0] }).first();
await tableRow.locator("button").first().click();
await expect(page.getByRole("link", { name: item.viewLabel || "View Profile" }).first()).toBeVisible();
}
});
});

View file

@ -0,0 +1,30 @@
// vite.config.ts
import { defineConfig } from "@solidjs/start/config";
import tailwindcss from "@tailwindcss/vite";
var vite_config_default = defineConfig({
ssr: false,
vite: {
plugins: [tailwindcss()],
server: {
proxy: {
"/api": {
target: "http://localhost:9100",
changeOrigin: true,
secure: false,
ws: true,
configureProxy: (proxy) => {
proxy.on("proxyReq", (proxyReq, req) => {
const cookie = req.headers.cookie;
if (cookie) {
proxyReq.setHeader("Cookie", cookie);
}
});
}
}
}
}
}
});
export {
vite_config_default as default
};