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

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

View file

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

View file

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

View file

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

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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

@ -0,0 +1 @@
61044

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

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

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

@ -0,0 +1 @@
72260

View file

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

16
package-lock.json generated
View file

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

View file

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

35
screenshot.ts Normal file
View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,18 @@ const SESSION_TTL_SECONDS = 60 * 60 * 12;
export function hasAdminSession(): boolean { export function hasAdminSession(): boolean {
if (typeof document === 'undefined') return false; 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 { export function setAdminSession(): void {

View file

@ -20,15 +20,18 @@ export function forwardCookies(request: Request): Record<string, string> {
/** /**
* Merge auth + cookie headers from the incoming request with any extra headers provided. * 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( export function withAuthHeaders(
request: Request, request: Request,
extra: Record<string, string> = {}, extra: Record<string, string> = {},
): Record<string, string> { ): Record<string, string> {
return { return {
'Content-Type': 'application/json',
...forwardAuth(request), ...forwardAuth(request),
...forwardCookies(request), ...forwardCookies(request),
...extra, ...extra,
// Default Content-Type only if extra didn't already provide one
...(extra['Content-Type'] ? {} : { 'Content-Type': 'application/json' }),
}; };
} }

View file

@ -1,36 +1,37 @@
import { A, useParams } from '@solidjs/router'; import { A, useParams } from "@solidjs/router";
import { createMemo } from 'solid-js'; import { createMemo, lazy } from "solid-js";
import ApprovalManagementPage from './approval';
import VerificationManagementPage from './verification'; const ApprovalManagementPage = lazy(() => import("./approval"));
import UsersManagementPage from './users'; const VerificationManagementPage = lazy(() => import("./verification"));
import ExternalDashboardManagementPage from './external-dashboard-management'; const UsersManagementPage = lazy(() => import("./users"));
import InternalDashboardManagementPage from './internal-dashboard-management'; const ExternalDashboardManagementPage = lazy(() => import("./external-dashboard-management"));
const InternalDashboardManagementPage = lazy(() => import("./internal-dashboard-management"));
function toTitle(value: string): string { function toTitle(value: string): string {
return value return value
.split(/[-_/]/g) .split(/[-_/]/g)
.filter(Boolean) .filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .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 { function resolveLegacyPath(modulePath: string): string {
switch (modulePath) { switch (modulePath) {
case 'roles': case "roles":
return '/roles?scope=internal'; return "/roles?scope=internal";
case 'approval-management': case "approval-management":
case 'approvals': case "approvals":
return '/approval'; return "/approval";
case 'onboarding-management': case "onboarding-management":
return '/external-dashboard-management'; return "/external-dashboard-management";
case 'internal-dashboard-management': case "internal-dashboard-management":
return '/internal-dashboard-management'; return "/internal-dashboard-management";
case 'external-dashboard-management': case "external-dashboard-management":
return '/external-dashboard-management'; return "/external-dashboard-management";
case 'support': case "support":
return '/help'; return "/help";
default: default:
return `/${modulePath}`; return `/${modulePath}`;
} }
@ -38,29 +39,42 @@ function resolveLegacyPath(modulePath: string): string {
export default function LegacyModuleShellPage() { export default function LegacyModuleShellPage() {
const params = useParams(); 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 />; return <ApprovalManagementPage />;
} }
if (modulePath === 'verification' || modulePath === 'verification-status' || modulePath === 'verification-management') { if (
modulePath === "verification" ||
modulePath === "verification-status" ||
modulePath === "verification-management"
) {
return <VerificationManagementPage />; return <VerificationManagementPage />;
} }
if (modulePath === 'users' || modulePath === 'users-management' || modulePath === 'user-management') { if (
modulePath === "users" ||
modulePath === "users-management" ||
modulePath === "user-management"
) {
return <UsersManagementPage />; return <UsersManagementPage />;
} }
if (modulePath === 'external-dashboard-management' || modulePath === 'onboarding-management') { if (modulePath === "external-dashboard-management" || modulePath === "onboarding-management") {
return <ExternalDashboardManagementPage />; return <ExternalDashboardManagementPage />;
} }
if (modulePath === 'internal-dashboard-management') { if (modulePath === "internal-dashboard-management") {
return <InternalDashboardManagementPage />; return <InternalDashboardManagementPage />;
} }
const moduleName = createMemo(() => toTitle(modulePath || 'Management')); const moduleName = createMemo(() => toTitle(modulePath || "Management"));
const legacyPath = createMemo(() => resolveLegacyPath(modulePath)); const legacyPath = createMemo(() => resolveLegacyPath(modulePath));
const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`); const legacyUrl = createMemo(() => `${LEGACY_ADMIN_ORIGIN}${legacyPath()}`);
@ -72,12 +86,24 @@ export default function LegacyModuleShellPage() {
</p> </p>
<section class="rounded-xl border border-gray-200 bg-white shadow-sm"> <section class="rounded-xl border border-gray-200 bg-white shadow-sm">
<div class="actions"> <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> </div>
<iframe <iframe
src={legacyUrl()} src={legacyUrl()}
title={`${moduleName()} (Legacy)`} 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> </section>
</div> </div>

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -48,7 +48,16 @@ export default function JobsManagementPage() {
const load = async () => { const load = async () => {
try { 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'); if (!res.ok) throw new Error('Fetch failed');
const data = await res.json(); const data = await res.json();
const list = Array.isArray(data) ? data : (data.jobs || []); const list = Array.isArray(data) ? data : (data.jobs || []);

View file

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

View file

@ -11,7 +11,16 @@ const ROLE_OPTIONS = [
async function loadLeads(): Promise<any[]> { async function loadLeads(): Promise<any[]> {
try { 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'); if (!res.ok) throw new Error('Failed to load');
const data = await res.json(); const data = await res.json();
return Array.isArray(data) ? data : (data.leads || []); return Array.isArray(data) ? data : (data.leads || []);

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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