Resolve conflicts: remove Woodpecker CI, use Gitea
Some checks failed
build-and-push / build (push) Failing after 10s
Some checks failed
build-and-push / build (push) Failing after 10s
This commit is contained in:
commit
ee45ea44db
47 changed files with 13421 additions and 6743 deletions
277
.gitea/scripts/registry_prune.py
Normal file
277
.gitea/scripts/registry_prune.py
Normal 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()
|
||||
145
.gitea/scripts/update-gitops.py
Normal file
145
.gitea/scripts/update-gitops.py
Normal 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()
|
||||
93
.gitea/workflows/build.yaml
Normal file
93
.gitea/workflows/build.yaml
Normal 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
46
.github/workflows/sync-to-gitea.yml
vendored
Normal 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
|
||||
2045
.playwright-cli/console-2026-04-21T21-45-00-110Z.log
Normal file
2045
.playwright-cli/console-2026-04-21T21-45-00-110Z.log
Normal file
File diff suppressed because it is too large
Load diff
70
.playwright-cli/page-2026-04-21T21-45-02-136Z.yml
Normal file
70
.playwright-cli/page-2026-04-21T21-45-02-136Z.yml
Normal 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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:20-alpine
|
||||
FROM registry.nxtgauge.com/node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
|
|
|||
|
|
@ -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
5
admin-solid.dev.log
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
|
||||
> dev
|
||||
> vinxi dev
|
||||
|
||||
vinxi v0.5.11
|
||||
1
admin-solid.dev.pid
Normal file
1
admin-solid.dev.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
61044
|
||||
1
admin-solid.start.log
Normal file
1
admin-solid.start.log
Normal file
|
|
@ -0,0 +1 @@
|
|||
Listening on http://[::]:3000
|
||||
1
admin-solid.start.pid
Normal file
1
admin-solid.start.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
72260
|
||||
71
admin.log
71
admin.log
|
|
@ -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
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
35
screenshot.ts
Normal 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);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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`} />
|
||||
<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={`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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,17 +207,17 @@ 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("");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -222,22 +232,33 @@ export default function CouponPage() {
|
|||
<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')}
|
||||
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'); }}
|
||||
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'}
|
||||
{form().id ? "Edit Coupon" : "Create Coupon"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Show when={activeTab() === 'list'}>
|
||||
<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
|
||||
|
|
@ -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>
|
||||
|
|
@ -373,11 +490,21 @@ export default function CouponPage() {
|
|||
</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={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>
|
||||
<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">
|
||||
|
|
@ -406,7 +533,9 @@ export default function CouponPage() {
|
|||
<label>Type</label>
|
||||
<select
|
||||
value={form().type}
|
||||
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form(), type: e.currentTarget.value as "PERCENT" | "FIXED" })
|
||||
}
|
||||
>
|
||||
<option value="PERCENT">Percent (%)</option>
|
||||
<option value="FIXED">Fixed (₹)</option>
|
||||
|
|
@ -429,7 +558,9 @@ export default function CouponPage() {
|
|||
<input
|
||||
type="number"
|
||||
value={form().min_order_amount}
|
||||
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
|
||||
onInput={(e) =>
|
||||
setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })
|
||||
}
|
||||
min="0"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
|
@ -444,9 +575,23 @@ export default function CouponPage() {
|
|||
placeholder="Unlimited"
|
||||
/>
|
||||
</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>
|
||||
<div>
|
||||
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
|
||||
<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) => {
|
||||
|
|
@ -455,7 +600,7 @@ export default function CouponPage() {
|
|||
<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'}`}
|
||||
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>
|
||||
|
|
@ -466,10 +611,16 @@ export default function CouponPage() {
|
|||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-primary" type="submit" disabled={saving()}>
|
||||
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
|
||||
{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>
|
||||
<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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1,2 @@
|
|||
export { default } from './designation';
|
||||
import Designation from "./designation";
|
||||
export default Designation;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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`, {
|
||||
const res = await fetch(
|
||||
`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Accept: "application/json",
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch(() => null);
|
||||
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,46 +160,76 @@ 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);
|
||||
}
|
||||
|
|
@ -189,13 +237,16 @@ export default function CreateEmployeePage() {
|
|||
|
||||
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>
|
||||
<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>
|
||||
<p class="mt-1 text-[14px] text-[#6B7280]">
|
||||
Dashboard / Employee Management / Add Employee
|
||||
</p>
|
||||
</div>
|
||||
<A
|
||||
href="/admin/employees"
|
||||
|
|
@ -209,23 +260,40 @@ export default function CreateEmployeePage() {
|
|||
<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>
|
||||
<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 */}
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
|
||||
Full Name <span class="text-red-500">*</span>
|
||||
First 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)}
|
||||
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]"
|
||||
/>
|
||||
|
|
@ -241,7 +309,7 @@ export default function CreateEmployeePage() {
|
|||
required
|
||||
placeholder="e.g. arjun@nxtgauge.com"
|
||||
value={email()}
|
||||
onInput={e => setEmail(e.currentTarget.value)}
|
||||
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>
|
||||
|
|
@ -255,7 +323,7 @@ export default function CreateEmployeePage() {
|
|||
type="text"
|
||||
readOnly
|
||||
value={employeeCode()}
|
||||
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
|
||||
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>
|
||||
|
|
@ -263,10 +331,18 @@ export default function CreateEmployeePage() {
|
|||
{/* 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]" />
|
||||
<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>
|
||||
<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()}>
|
||||
|
|
@ -275,25 +351,97 @@ export default function CreateEmployeePage() {
|
|||
<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="password"
|
||||
type={showLoginPassword() ? "text" : "password"}
|
||||
value={loginPassword()}
|
||||
onInput={e => setLoginPassword(e.currentTarget.value)}
|
||||
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]"
|
||||
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="password"
|
||||
type={showConfirmPassword() ? "text" : "password"}
|
||||
value={confirmLoginPassword()}
|
||||
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
|
||||
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]"
|
||||
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>
|
||||
|
|
@ -305,11 +453,11 @@ export default function CreateEmployeePage() {
|
|||
</label>
|
||||
<select
|
||||
value={roleId()}
|
||||
onChange={e => setRoleId(e.currentTarget.value)}
|
||||
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>
|
||||
<For each={roles() ?? []}>{(r) => <option value={r.key}>{r.name}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -320,11 +468,11 @@ export default function CreateEmployeePage() {
|
|||
</label>
|
||||
<select
|
||||
value={deptId()}
|
||||
onChange={e => setDeptId(e.currentTarget.value)}
|
||||
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>
|
||||
<For each={depts() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -335,14 +483,13 @@ export default function CreateEmployeePage() {
|
|||
</label>
|
||||
<select
|
||||
value={desigId()}
|
||||
onChange={e => setDesigId(e.currentTarget.value)}
|
||||
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>
|
||||
<For each={desigs() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
|
|
@ -352,7 +499,9 @@ export default function CreateEmployeePage() {
|
|||
|
||||
{/* Error */}
|
||||
{error() && (
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">{error()}</div>
|
||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
|
||||
{error()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
|
|
@ -368,12 +517,11 @@ export default function CreateEmployeePage() {
|
|||
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'}
|
||||
{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
|
|
@ -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,12 +158,14 @@ 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'
|
||||
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'
|
||||
const rawSize = String(layout?.size?.[key] || "").toUpperCase();
|
||||
size[key] =
|
||||
rawSize === "S" || rawSize === "M" || rawSize === "L"
|
||||
? (rawSize as DashboardWidgetSize)
|
||||
: definition.defaultSize;
|
||||
}
|
||||
|
|
@ -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) => ({
|
||||
|
|
@ -384,15 +404,25 @@ 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 don’t have access to {String(searchParams.denied || '').replace(/_/g, ' ')}.</p>
|
||||
<div
|
||||
class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm"
|
||||
style="margin-bottom: 18px"
|
||||
>
|
||||
<p class="notice">
|
||||
You don’t 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="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>
|
||||
<p class="mt-0.5 text-[13px] text-[#6B7280]">
|
||||
Manage widget layout, visibility, sizing, and dashboard presentation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0">
|
||||
|
|
@ -406,7 +436,7 @@ export default function AdminHomePage() {
|
|||
}}
|
||||
>
|
||||
<Settings2 size={14} class="text-[#FA5014]" />
|
||||
{settingsOpen() ? 'Close Settings' : 'Customize Widgets'}
|
||||
{settingsOpen() ? "Close Settings" : "Customize Widgets"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -418,7 +448,9 @@ export default function AdminHomePage() {
|
|||
<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>
|
||||
<p class="mt-1 text-[13px] text-[#6B7280]">
|
||||
Choose visible widgets and select a grid layout.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -433,7 +465,11 @@ export default function AdminHomePage() {
|
|||
{/* 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" />
|
||||
<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)}
|
||||
|
|
@ -489,24 +525,28 @@ export default function AdminHomePage() {
|
|||
</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>
|
||||
<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)}`}>
|
||||
<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]'
|
||||
? "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'}
|
||||
{visible() ? "Visible" : "Hidden"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -516,7 +556,9 @@ export default function AdminHomePage() {
|
|||
</div>
|
||||
|
||||
<Show when={isAutoSaving() || autoSaveNotice()}>
|
||||
<p class="mt-4 text-xs text-[#6B7280]">{isAutoSaving() ? 'Saving layout...' : autoSaveNotice()}</p>
|
||||
<p class="mt-4 text-xs text-[#6B7280]">
|
||||
{isAutoSaving() ? "Saving layout..." : autoSaveNotice()}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -537,8 +579,8 @@ export default function AdminHomePage() {
|
|||
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' : ''}`}
|
||||
state === "pending" ? "opacity-95" : ""
|
||||
} ${draggingKey() === definition.widgetKey ? "opacity-60" : ""}`}
|
||||
>
|
||||
<Show when={settingsOpen()}>
|
||||
<>
|
||||
|
|
@ -560,7 +602,8 @@ export default function AdminHomePage() {
|
|||
|
||||
<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) => (
|
||||
{["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]"
|
||||
|
|
@ -568,7 +611,8 @@ export default function AdminHomePage() {
|
|||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -576,22 +620,28 @@ export default function AdminHomePage() {
|
|||
</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="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 when={state === "live"}>
|
||||
<LivePreview
|
||||
value={stateInfo.data?.value}
|
||||
trend={stateInfo.data?.trend}
|
||||
trendUp={stateInfo.data?.trendUp}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state === 'empty'}>
|
||||
<Show when={state === "empty"}>
|
||||
<EmptyPreview />
|
||||
</Show>
|
||||
<Show when={state === 'pending'}>
|
||||
<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)}`}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,8 +42,11 @@ 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);
|
||||
|
|
@ -53,18 +56,28 @@ export default function JobDetailPage() {
|
|||
<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>
|
||||
<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>
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<p class="notice">Job not found.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={job()}>
|
||||
|
|
@ -72,11 +85,11 @@ export default function JobDetailPage() {
|
|||
<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>
|
||||
<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>
|
||||
<p style="margin:6px 0 0;color:#334155">{job()!.status || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="hint">Client</p>
|
||||
|
|
@ -88,28 +101,36 @@ export default function JobDetailPage() {
|
|||
</div>
|
||||
<div>
|
||||
<p class="hint">Rate</p>
|
||||
<p style="margin:6px 0 0;color:#334155">{rateMin() != null ? `₹${rateMin()} - ₹${rateMax() ?? rateMin()}` : '—'}</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">
|
||||
{job()!.description || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
|
|
|
|||
|
|
@ -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,33 +69,34 @@ 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">
|
||||
|
|
@ -105,17 +106,31 @@ export default function KbArticleEditPage() {
|
|||
<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>
|
||||
<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>
|
||||
<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()}>
|
||||
|
|
@ -123,36 +138,60 @@ export default function KbArticleEditPage() {
|
|||
<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 />
|
||||
<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)} />
|
||||
<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)} />
|
||||
<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)}>
|
||||
<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)} />
|
||||
<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>
|
||||
<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'}
|
||||
{saving() ? "Saving…" : "Save Article"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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 || []);
|
||||
|
|
|
|||
|
|
@ -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,100 +149,177 @@ 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
|
||||
|
|
@ -199,11 +329,19 @@ export default function PricingPage() {
|
|||
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
|
||||
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">
|
||||
<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>
|
||||
|
|
@ -215,7 +353,15 @@ export default function PricingPage() {
|
|||
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>
|
||||
<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">
|
||||
|
|
@ -223,8 +369,11 @@ export default function PricingPage() {
|
|||
{([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'}`}
|
||||
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>
|
||||
|
|
@ -233,13 +382,19 @@ export default function PricingPage() {
|
|||
</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}>
|
||||
<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>
|
||||
<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">
|
||||
|
|
@ -247,67 +402,168 @@ export default function PricingPage() {
|
|||
<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>
|
||||
<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="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
||||
<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="7" style="text-align:center;padding:32px;color:#94a3b8">No packages found.</td></tr>
|
||||
<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}</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 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-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 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`}
|
||||
>
|
||||
<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'}
|
||||
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'}
|
||||
{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">
|
||||
<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>
|
||||
<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" />
|
||||
<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" />
|
||||
<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" />
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -330,42 +586,194 @@ export default function PricingPage() {
|
|||
</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 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</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" />
|
||||
<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 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">
|
||||
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">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: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>
|
||||
<button class="btn-primary" type="submit" disabled={cSaving()}>{cSaving() ? 'Creating...' : 'Create Package'}</button>
|
||||
<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: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>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,50 +165,80 @@ 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);
|
||||
}
|
||||
|
|
@ -189,35 +246,42 @@ export default function CreateInternalRolePage() {
|
|||
|
||||
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]">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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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' },
|
||||
{ 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]'
|
||||
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
|
|
@ -236,7 +300,7 @@ export default function CreateInternalRolePage() {
|
|||
</Show>
|
||||
|
||||
{/* ── Tab: General Information ── */}
|
||||
<Show when={subTab() === 'general'}>
|
||||
<Show when={subTab() === "general"}>
|
||||
<div class="p-6 space-y-5">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
|
|
@ -296,7 +360,7 @@ export default function CreateInternalRolePage() {
|
|||
</Show>
|
||||
|
||||
{/* ── Tab: Module Access ── */}
|
||||
<Show when={subTab() === 'module'}>
|
||||
<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.
|
||||
|
|
@ -316,7 +380,7 @@ export default function CreateInternalRolePage() {
|
|||
onClick={() => (allSelected() ? deselectAll() : selectAll())}
|
||||
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
|
||||
>
|
||||
{allSelected() ? 'Deselect All' : 'Select All'}
|
||||
{allSelected() ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -324,7 +388,10 @@ export default function CreateInternalRolePage() {
|
|||
<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)]">
|
||||
<td
|
||||
colspan="6"
|
||||
class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"
|
||||
>
|
||||
Loading modules…
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -334,7 +401,9 @@ export default function CreateInternalRolePage() {
|
|||
const perms = () => permsByModule()[module] ?? [];
|
||||
const byAction = () => {
|
||||
const m: Record<string, Permission> = {};
|
||||
perms().forEach((p) => { m[p.action] = p; });
|
||||
perms().forEach((p) => {
|
||||
m[p.action] = p;
|
||||
});
|
||||
return m;
|
||||
};
|
||||
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
|
||||
|
|
@ -380,7 +449,7 @@ export default function CreateInternalRolePage() {
|
|||
</Show>
|
||||
|
||||
{/* ── Tab: Role Settings ── */}
|
||||
<Show when={subTab() === 'settings'}>
|
||||
<Show when={subTab() === "settings"}>
|
||||
<div class="p-6 space-y-6">
|
||||
{/* Status toggle */}
|
||||
<div>
|
||||
|
|
@ -391,8 +460,8 @@ export default function CreateInternalRolePage() {
|
|||
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]'
|
||||
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
|
||||
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
||||
}`}
|
||||
>
|
||||
Active
|
||||
|
|
@ -402,8 +471,8 @@ export default function CreateInternalRolePage() {
|
|||
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]'
|
||||
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
|
||||
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
||||
}`}
|
||||
>
|
||||
Inactive
|
||||
|
|
@ -443,11 +512,10 @@ export default function CreateInternalRolePage() {
|
|||
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'}
|
||||
{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
|
|
@ -1,19 +1,19 @@
|
|||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
||||
import { A } from '@solidjs/router';
|
||||
import { createResource, createSignal, createMemo, Show, For } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
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(contentType = false): Record<string, string> {
|
||||
const token = getToken();
|
||||
return {
|
||||
Accept: 'application/json',
|
||||
...(contentType ? { 'Content-Type': 'application/json' } : {}),
|
||||
Accept: "application/json",
|
||||
...(contentType ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
|
@ -22,9 +22,14 @@ type SupportCase = {
|
|||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'platform_issue' | 'customer_query' | 'professional_query' | 'billing_issue' | 'lead_dispute';
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
status: 'new' | 'in_progress' | 'waiting_for_user' | 'resolved' | 'closed';
|
||||
type:
|
||||
| "platform_issue"
|
||||
| "customer_query"
|
||||
| "professional_query"
|
||||
| "billing_issue"
|
||||
| "lead_dispute";
|
||||
priority: "low" | "medium" | "high" | "critical";
|
||||
status: "new" | "in_progress" | "waiting_for_user" | "resolved" | "closed";
|
||||
requesterName?: string;
|
||||
requesterEmail?: string;
|
||||
updatedAt: string;
|
||||
|
|
@ -37,55 +42,68 @@ type AssigneeOption = {
|
|||
email?: string;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: SupportCase['status'][] = ['new', 'in_progress', 'waiting_for_user', 'resolved', 'closed'];
|
||||
const TYPE_OPTIONS: SupportCase['type'][] = ['platform_issue', 'customer_query', 'professional_query', 'billing_issue', 'lead_dispute'];
|
||||
const PRIORITY_OPTIONS: SupportCase['priority'][] = ['low', 'medium', 'high', 'critical'];
|
||||
const STATUS_OPTIONS: SupportCase["status"][] = [
|
||||
"new",
|
||||
"in_progress",
|
||||
"waiting_for_user",
|
||||
"resolved",
|
||||
"closed",
|
||||
];
|
||||
const TYPE_OPTIONS: SupportCase["type"][] = [
|
||||
"platform_issue",
|
||||
"customer_query",
|
||||
"professional_query",
|
||||
"billing_issue",
|
||||
"lead_dispute",
|
||||
];
|
||||
const PRIORITY_OPTIONS: SupportCase["priority"][] = ["low", "medium", "high", "critical"];
|
||||
|
||||
function formatValue(input: string): string {
|
||||
return input.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return input.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function typeBadgeStyle(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
platform_issue: 'background:#dbeafe;color:#1d4ed8',
|
||||
customer_query: 'background:#dcfce7;color:#15803d',
|
||||
billing_issue: 'background:#ffedd5;color:#c2410c',
|
||||
lead_dispute: 'background:#fee2e2;color:#b91c1c',
|
||||
professional_query: 'background:#f3e8ff;color:#7e22ce',
|
||||
platform_issue: "background:#dbeafe;color:#1d4ed8",
|
||||
customer_query: "background:#dcfce7;color:#15803d",
|
||||
billing_issue: "background:#ffedd5;color:#c2410c",
|
||||
lead_dispute: "background:#fee2e2;color:#b91c1c",
|
||||
professional_query: "background:#f3e8ff;color:#7e22ce",
|
||||
};
|
||||
return map[type] || 'background:#f1f5f9;color:#475569';
|
||||
return map[type] || "background:#f1f5f9;color:#475569";
|
||||
}
|
||||
|
||||
function priorityBadgeStyle(priority: string): string {
|
||||
const map: Record<string, string> = {
|
||||
low: 'background:#f1f5f9;color:#475569',
|
||||
medium: 'background:#dbeafe;color:#1d4ed8',
|
||||
high: 'background:#ffedd5;color:#c2410c',
|
||||
critical: 'background:#fee2e2;color:#b91c1c',
|
||||
low: "background:#f1f5f9;color:#475569",
|
||||
medium: "background:#dbeafe;color:#1d4ed8",
|
||||
high: "background:#ffedd5;color:#c2410c",
|
||||
critical: "background:#fee2e2;color:#b91c1c",
|
||||
};
|
||||
return map[priority] || 'background:#f1f5f9;color:#475569';
|
||||
return map[priority] || "background:#f1f5f9;color:#475569";
|
||||
}
|
||||
|
||||
function statusBadgeStyle(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
new: 'background:#dbeafe;color:#1d4ed8',
|
||||
in_progress: 'background:#ffedd5;color:#c2410c',
|
||||
waiting_for_user: 'background:#fef9c3;color:#a16207',
|
||||
resolved: 'background:#dcfce7;color:#15803d',
|
||||
closed: 'background:#f1f5f9;color:#475569',
|
||||
new: "background:#dbeafe;color:#1d4ed8",
|
||||
in_progress: "background:#ffedd5;color:#c2410c",
|
||||
waiting_for_user: "background:#fef9c3;color:#a16207",
|
||||
resolved: "background:#dcfce7;color:#15803d",
|
||||
closed: "background:#f1f5f9;color:#475569",
|
||||
};
|
||||
return map[status] || 'background:#f1f5f9;color:#475569';
|
||||
return map[status] || "background:#f1f5f9;color:#475569";
|
||||
}
|
||||
|
||||
const BADGE_STYLE = 'display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600';
|
||||
const BADGE_STYLE =
|
||||
"display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600";
|
||||
|
||||
async function loadAllCases(): Promise<SupportCase[]> {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
|
|
@ -95,29 +113,31 @@ async function loadAllCases(): Promise<SupportCase[]> {
|
|||
|
||||
async function loadAssignees(): Promise<AssigneeOption[]> {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: '1', per_page: '200', sort: 'joined_desc' });
|
||||
const params = new URLSearchParams({ page: "1", per_page: "200", sort: "joined_desc" });
|
||||
const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
|
||||
headers: authHeaders(),
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const data = await res.json();
|
||||
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||||
return raw.map((item: any) => ({
|
||||
id: String(item.id ?? ''),
|
||||
name: String(item.name ?? item.full_name ?? item.email ?? 'Unknown'),
|
||||
return raw
|
||||
.map((item: any) => ({
|
||||
id: String(item.id ?? ""),
|
||||
name: String(item.name ?? item.full_name ?? item.email ?? "Unknown"),
|
||||
email: item.email ? String(item.email) : undefined,
|
||||
})).filter((item: AssigneeOption) => Boolean(item.id));
|
||||
}))
|
||||
.filter((item: AssigneeOption) => Boolean(item.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default function SupportPage() {
|
||||
const [activeTab, setActiveTab] = createSignal<'queue' | 'create'>('queue');
|
||||
const [statusFilter, setStatusFilter] = createSignal<'all' | SupportCase['status']>('all');
|
||||
const [search, setSearch] = createSignal('');
|
||||
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'priority'>('newest');
|
||||
const [activeTab, setActiveTab] = createSignal<"queue" | "create">("queue");
|
||||
const [statusFilter, setStatusFilter] = createSignal<"all" | SupportCase["status"]>("all");
|
||||
const [search, setSearch] = createSignal("");
|
||||
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "priority">("newest");
|
||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||
const [refetchKey, setRefetchKey] = createSignal(0);
|
||||
|
|
@ -131,21 +151,39 @@ export default function SupportPage() {
|
|||
let all = cases() ?? [];
|
||||
const q = search().toLowerCase().trim();
|
||||
if (q) {
|
||||
all = all.filter((c) =>
|
||||
String(c.title || '').toLowerCase().includes(q)
|
||||
|| String(c.description || '').toLowerCase().includes(q)
|
||||
|| String(c.requesterName || '').toLowerCase().includes(q)
|
||||
|| String(c.requesterEmail || '').toLowerCase().includes(q)
|
||||
|| String(c.type || '').toLowerCase().includes(q)
|
||||
all = all.filter(
|
||||
(c) =>
|
||||
String(c.title || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(c.description || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(c.requesterName || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(c.requesterEmail || "")
|
||||
.toLowerCase()
|
||||
.includes(q) ||
|
||||
String(c.type || "")
|
||||
.toLowerCase()
|
||||
.includes(q)
|
||||
);
|
||||
}
|
||||
const sf = statusFilter();
|
||||
if (sf !== 'all') all = all.filter((c) => c.status === sf);
|
||||
const priorityRank: Record<SupportCase['priority'], number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
||||
if (sf !== "all") all = all.filter((c) => c.status === sf);
|
||||
const priorityRank: Record<SupportCase["priority"], number> = {
|
||||
critical: 4,
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
};
|
||||
const sorted = [...all];
|
||||
sorted.sort((a, b) => {
|
||||
if (sortBy() === 'oldest') return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||||
if (sortBy() === 'priority') return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
||||
if (sortBy() === "oldest")
|
||||
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||||
if (sortBy() === "priority")
|
||||
return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
||||
return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
|
||||
});
|
||||
return sorted;
|
||||
|
|
@ -154,49 +192,49 @@ export default function SupportPage() {
|
|||
const stats = createMemo(() => {
|
||||
const all = cases() ?? [];
|
||||
return {
|
||||
newCount: all.filter((c) => c.status === 'new').length,
|
||||
inProgressCount: all.filter((c) => c.status === 'in_progress').length,
|
||||
waitingCount: all.filter((c) => c.status === 'waiting_for_user').length,
|
||||
newCount: all.filter((c) => c.status === "new").length,
|
||||
inProgressCount: all.filter((c) => c.status === "in_progress").length,
|
||||
waitingCount: all.filter((c) => c.status === "waiting_for_user").length,
|
||||
total: all.length,
|
||||
};
|
||||
});
|
||||
|
||||
// Create Case form state
|
||||
const [fTitle, setFTitle] = createSignal('');
|
||||
const [fDesc, setFDesc] = createSignal('');
|
||||
const [fType, setFType] = createSignal<SupportCase['type']>('customer_query');
|
||||
const [fPriority, setFPriority] = createSignal<SupportCase['priority']>('medium');
|
||||
const [fRequesterName, setFRequesterName] = createSignal('');
|
||||
const [fRequesterEmail, setFRequesterEmail] = createSignal('');
|
||||
const [fAssignedTo, setFAssignedTo] = createSignal('');
|
||||
const [fTitle, setFTitle] = createSignal("");
|
||||
const [fDesc, setFDesc] = createSignal("");
|
||||
const [fType, setFType] = createSignal<SupportCase["type"]>("customer_query");
|
||||
const [fPriority, setFPriority] = createSignal<SupportCase["priority"]>("medium");
|
||||
const [fRequesterName, setFRequesterName] = createSignal("");
|
||||
const [fRequesterEmail, setFRequesterEmail] = createSignal("");
|
||||
const [fAssignedTo, setFAssignedTo] = createSignal("");
|
||||
const [createLoading, setCreateLoading] = createSignal(false);
|
||||
const [createSuccess, setCreateSuccess] = createSignal('');
|
||||
const [createError, setCreateError] = createSignal('');
|
||||
const [createSuccess, setCreateSuccess] = createSignal("");
|
||||
const [createError, setCreateError] = createSignal("");
|
||||
|
||||
const resetForm = () => {
|
||||
setFTitle('');
|
||||
setFDesc('');
|
||||
setFType('customer_query');
|
||||
setFPriority('medium');
|
||||
setFRequesterName('');
|
||||
setFRequesterEmail('');
|
||||
setFAssignedTo('');
|
||||
setFTitle("");
|
||||
setFDesc("");
|
||||
setFType("customer_query");
|
||||
setFPriority("medium");
|
||||
setFRequesterName("");
|
||||
setFRequesterEmail("");
|
||||
setFAssignedTo("");
|
||||
};
|
||||
|
||||
const handleCreate = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setCreateLoading(true);
|
||||
setCreateSuccess('');
|
||||
setCreateError('');
|
||||
setCreateSuccess("");
|
||||
setCreateError("");
|
||||
try {
|
||||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
headers: authHeaders(true),
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
title: fTitle(),
|
||||
description: fDesc(),
|
||||
type: fType(),
|
||||
category: fType(),
|
||||
priority: fPriority(),
|
||||
requesterName: fRequesterName(),
|
||||
requesterEmail: fRequesterEmail(),
|
||||
|
|
@ -204,52 +242,54 @@ export default function SupportPage() {
|
|||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error((d as any).message || 'Failed to create case');
|
||||
throw new Error((d as any).message || "Failed to create case");
|
||||
}
|
||||
const created = await res.json().catch(() => ({}));
|
||||
const createdId = String((created as any)?.id || '');
|
||||
const createdId = String((created as any)?.id || "");
|
||||
if (createdId && fAssignedTo()) {
|
||||
await fetch(`${API}/api/admin/support-cases/${createdId}`, {
|
||||
method: 'PATCH',
|
||||
method: "PATCH",
|
||||
headers: authHeaders(true),
|
||||
credentials: 'include',
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ assigned_to: fAssignedTo() }),
|
||||
});
|
||||
}
|
||||
setCreateSuccess('Case created!');
|
||||
setCreateSuccess("Case created!");
|
||||
resetForm();
|
||||
refetch();
|
||||
setActiveTab('queue');
|
||||
setActiveTab("queue");
|
||||
} catch (err: any) {
|
||||
setCreateError(err.message || 'Failed to create case');
|
||||
setCreateError(err.message || "Failed to create case");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: 'New', getValue: () => stats().newCount },
|
||||
{ label: 'In Progress', getValue: () => stats().inProgressCount },
|
||||
{ label: 'Waiting', getValue: () => stats().waitingCount },
|
||||
{ label: 'Total', getValue: () => stats().total },
|
||||
{ label: "New", getValue: () => stats().newCount },
|
||||
{ label: "In Progress", getValue: () => stats().inProgressCount },
|
||||
{ label: "Waiting", getValue: () => stats().waitingCount },
|
||||
{ label: "Total", getValue: () => stats().total },
|
||||
];
|
||||
|
||||
const exportCsv = () => {
|
||||
const headers = ['Issue', 'Type', 'Priority', 'Status', 'Requester', 'Updated'];
|
||||
const headers = ["Issue", "Type", "Priority", "Status", "Requester", "Updated"];
|
||||
const rows = filteredCases().map((item) => [
|
||||
item.title,
|
||||
item.type,
|
||||
item.priority,
|
||||
item.status,
|
||||
item.requesterEmail || item.requesterName || '—',
|
||||
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—',
|
||||
item.requesterEmail || item.requesterName || "—",
|
||||
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—",
|
||||
]);
|
||||
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;' });
|
||||
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;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = 'support-management.csv';
|
||||
link.download = "support-management.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
|
@ -265,19 +305,23 @@ export default function SupportPage() {
|
|||
<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() === 'queue'
|
||||
? '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('queue')}
|
||||
class={
|
||||
activeTab() === "queue"
|
||||
? "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("queue")}
|
||||
>
|
||||
Support Queue
|
||||
</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={() => setActiveTab('create')}
|
||||
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={() => setActiveTab("create")}
|
||||
>
|
||||
Create Case
|
||||
</button>
|
||||
|
|
@ -297,7 +341,7 @@ export default function SupportPage() {
|
|||
</div>
|
||||
|
||||
{/* Support Queue Tab */}
|
||||
<Show when={activeTab() === 'queue'}>
|
||||
<Show when={activeTab() === "queue"}>
|
||||
<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
|
||||
|
|
@ -308,42 +352,123 @@ export default function SupportPage() {
|
|||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||
/>
|
||||
<div style="position:relative;">
|
||||
<button type="button" 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>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
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: 'priority', label: 'Priority High-Low' },
|
||||
] as { key: 'newest' | 'oldest' | 'priority'; label: string }[]}>
|
||||
<For
|
||||
each={
|
||||
[
|
||||
{ key: "newest", label: "Newest First" },
|
||||
{ key: "oldest", label: "Oldest First" },
|
||||
{ key: "priority", label: "Priority High-Low" },
|
||||
] as { key: "newest" | "oldest" | "priority"; 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'}`}>{item.label}</button>
|
||||
<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>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<button type="button" 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>
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
Filters
|
||||
</button>
|
||||
<Show when={filterMenuOpen()}>
|
||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:220px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||
<button type="button" onClick={() => { setStatusFilter('all'); 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() === 'all' ? '#FF5E13' : '#374151'};background:${statusFilter() === 'all' ? '#FFF1EB' : 'transparent'}`}>All statuses</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter("all");
|
||||
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() === "all" ? "#FF5E13" : "#374151"};background:${statusFilter() === "all" ? "#FFF1EB" : "transparent"}`}
|
||||
>
|
||||
All statuses
|
||||
</button>
|
||||
<For each={STATUS_OPTIONS}>
|
||||
{(s) => (
|
||||
<button type="button" onClick={() => { setStatusFilter(s); 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() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>{formatValue(s)}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStatusFilter(s);
|
||||
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() === s ? "#FF5E13" : "#374151"};background:${statusFilter() === s ? "#FFF1EB" : "transparent"}`}
|
||||
>
|
||||
{formatValue(s)}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</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>
|
||||
|
|
@ -364,13 +489,25 @@ export default function SupportPage() {
|
|||
</thead>
|
||||
<tbody>
|
||||
<Show when={cases.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={!cases.loading && cases.error}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load cases.</td></tr>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">
|
||||
Failed to load cases.
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}>
|
||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No support cases found.</td></tr>
|
||||
<tr>
|
||||
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
|
||||
No support cases found.
|
||||
</td>
|
||||
</tr>
|
||||
</Show>
|
||||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||||
<For each={filteredCases()}>
|
||||
|
|
@ -378,27 +515,42 @@ export default function SupportPage() {
|
|||
<tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}>
|
||||
<td>
|
||||
<div class="font-semibold text-slate-900">{item.title}</div>
|
||||
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{item.description}</div>
|
||||
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
{item.description}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>{formatValue(item.type)}</span>
|
||||
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>
|
||||
{formatValue(item.type)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>{formatValue(item.priority)}</span>
|
||||
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>
|
||||
{formatValue(item.priority)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>{formatValue(item.status)}</span>
|
||||
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>
|
||||
{formatValue(item.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="font-size:13px">{item.requesterName || '—'}</div>
|
||||
<div style="font-size:11px;color:#64748b">{item.requesterEmail || ''}</div>
|
||||
<div style="font-size:13px">{item.requesterName || "—"}</div>
|
||||
<div style="font-size:11px;color:#64748b">
|
||||
{item.requesterEmail || ""}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-slate-500" style="font-size:12px">
|
||||
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—'}
|
||||
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<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={`/admin/support/${item.id}`}>View</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={`/admin/support/${item.id}`}
|
||||
>
|
||||
View
|
||||
</A>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -411,14 +563,45 @@ export default function SupportPage() {
|
|||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
|
||||
<p style="font-size:13px;color:#6B7280">
|
||||
Showing <strong style="font-weight:600;color:#111827">1–{filteredCases().length}</strong> of <strong style="font-weight:600;color:#111827">{filteredCases().length}</strong> cases
|
||||
Showing{" "}
|
||||
<strong style="font-weight:600;color:#111827">
|
||||
1–{filteredCases().length}
|
||||
</strong>{" "}
|
||||
of{" "}
|
||||
<strong style="font-weight:600;color:#111827">{filteredCases().length}</strong>{" "}
|
||||
cases
|
||||
</p>
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px">‹</button>
|
||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
|
||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">2</button>
|
||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">3</button>
|
||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px">›</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer"
|
||||
>
|
||||
1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||||
>
|
||||
2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||||
>
|
||||
3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
@ -427,11 +610,14 @@ export default function SupportPage() {
|
|||
</Show>
|
||||
|
||||
{/* Create Case Tab */}
|
||||
<Show when={activeTab() === 'create'}>
|
||||
<Show when={activeTab() === "create"}>
|
||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Create Support Case</h2>
|
||||
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">
|
||||
Create Support Case
|
||||
</h2>
|
||||
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
|
||||
Create an internal support record for platform issues, customer concerns, or compensation-related reviews.
|
||||
Create an internal support record for platform issues, customer concerns, or
|
||||
compensation-related reviews.
|
||||
</p>
|
||||
<Show when={createSuccess()}>
|
||||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
|
||||
|
|
@ -439,11 +625,18 @@ export default function SupportPage() {
|
|||
</div>
|
||||
</Show>
|
||||
<Show when={createError()}>
|
||||
<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:14px">{createError()}</div>
|
||||
<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:14px"
|
||||
>
|
||||
{createError()}
|
||||
</div>
|
||||
</Show>
|
||||
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
|
|
@ -453,7 +646,9 @@ export default function SupportPage() {
|
|||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows="4"
|
||||
|
|
@ -464,10 +659,12 @@ export default function SupportPage() {
|
|||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
value={fType()}
|
||||
onChange={(e) => setFType(e.currentTarget.value as SupportCase['type'])}
|
||||
onChange={(e) => setFType(e.currentTarget.value as SupportCase["type"])}
|
||||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||||
>
|
||||
<For each={TYPE_OPTIONS}>
|
||||
|
|
@ -476,10 +673,12 @@ export default function SupportPage() {
|
|||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Priority</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={fPriority()}
|
||||
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase['priority'])}
|
||||
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase["priority"])}
|
||||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||||
>
|
||||
<For each={PRIORITY_OPTIONS}>
|
||||
|
|
@ -490,7 +689,9 @@ export default function SupportPage() {
|
|||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Name</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Requester Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fRequesterName()}
|
||||
|
|
@ -499,7 +700,9 @@ export default function SupportPage() {
|
|||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Email</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Requester Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={fRequesterEmail()}
|
||||
|
|
@ -509,7 +712,9 @@ export default function SupportPage() {
|
|||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Assign To (optional)</label>
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||
Assign To (optional)
|
||||
</label>
|
||||
<select
|
||||
value={fAssignedTo()}
|
||||
onChange={(e) => setFAssignedTo(e.currentTarget.value)}
|
||||
|
|
@ -519,7 +724,8 @@ export default function SupportPage() {
|
|||
<For each={assignees()}>
|
||||
{(assignee) => (
|
||||
<option value={assignee.id}>
|
||||
{assignee.name}{assignee.email ? ` (${assignee.email})` : ''}
|
||||
{assignee.name}
|
||||
{assignee.email ? ` (${assignee.email})` : ""}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -527,7 +733,7 @@ export default function SupportPage() {
|
|||
</div>
|
||||
<div>
|
||||
<button class="btn-primary" type="submit" disabled={createLoading()}>
|
||||
{createLoading() ? 'Creating...' : 'Create Support Case'}
|
||||
{createLoading() ? "Creating..." : "Create Support Case"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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,60 +52,64 @@ 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);
|
||||
}
|
||||
|
|
@ -116,25 +120,42 @@ export default function EditUserPage() {
|
|||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<p class="notice">User not found.</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={user()}>
|
||||
|
|
@ -142,15 +163,28 @@ export default function EditUserPage() {
|
|||
<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)} />
|
||||
<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)} />
|
||||
<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)}>
|
||||
<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) => (
|
||||
|
|
@ -161,7 +195,13 @@ export default function EditUserPage() {
|
|||
</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')}>
|
||||
<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>
|
||||
|
|
@ -170,9 +210,15 @@ export default function EditUserPage() {
|
|||
</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="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'}
|
||||
{submitting() ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
36
src/routes/api/me/notifications/unread-count.ts
Normal file
36
src/routes/api/me/notifications/unread-count.ts
Normal 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' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
368
tests/e2e/verification-approval-role-lifecycle.spec.ts
Normal file
368
tests/e2e/verification-approval-role-lifecycle.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
30
vite.config.timestamp_1776803609838.js
Normal file
30
vite.config.timestamp_1776803609838.js
Normal 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
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue