fix: update admin forms to match DB schema
This commit is contained in:
parent
3265b1a16a
commit
232309e353
12 changed files with 4520 additions and 2820 deletions
0
admin-solid.dev.log
Normal file
0
admin-solid.dev.log
Normal file
1
admin-solid.dev.pid
Normal file
1
admin-solid.dev.pid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
7741
|
||||||
|
|
@ -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,17 +207,17 @@ 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("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -222,22 +232,33 @@ export default function CouponPage() {
|
||||||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
<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"
|
||||||
|
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
}
|
||||||
|
onClick={() => setActiveTab("list")}
|
||||||
>
|
>
|
||||||
Coupons
|
Coupons
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={activeTab() === 'create' ? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium' : 'py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors'}
|
class={
|
||||||
onClick={() => { resetForm(); setActiveTab('create'); }}
|
activeTab() === "create"
|
||||||
|
? "py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium"
|
||||||
|
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
setActiveTab("create");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{form().id ? 'Edit Coupon' : 'Create Coupon'}
|
{form().id ? "Edit Coupon" : "Create Coupon"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -373,11 +490,21 @@ export default function CouponPage() {
|
||||||
</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"
|
||||||
|
style="max-width:520px"
|
||||||
|
>
|
||||||
|
<h2 style="margin:0 0 20px;font-size:16px;font-weight:700">
|
||||||
|
{form().id ? "Edit Coupon" : "Create Coupon"}
|
||||||
|
</h2>
|
||||||
<Show when={formError()}>
|
<Show when={formError()}>
|
||||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:12px">{formError()}</div>
|
<div
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||||
|
style="margin-bottom:12px"
|
||||||
|
>
|
||||||
|
{formError()}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
|
<form onSubmit={handleSave} style="display:flex;flex-direction:column;gap:14px">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
@ -406,7 +533,9 @@ export default function CouponPage() {
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<select
|
<select
|
||||||
value={form().type}
|
value={form().type}
|
||||||
onChange={(e) => setForm({ ...form(), type: e.currentTarget.value as 'PERCENT' | 'FIXED' })}
|
onChange={(e) =>
|
||||||
|
setForm({ ...form(), type: e.currentTarget.value as "PERCENT" | "FIXED" })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<option value="PERCENT">Percent (%)</option>
|
<option value="PERCENT">Percent (%)</option>
|
||||||
<option value="FIXED">Fixed (₹)</option>
|
<option value="FIXED">Fixed (₹)</option>
|
||||||
|
|
@ -429,7 +558,9 @@ export default function CouponPage() {
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={form().min_order_amount}
|
value={form().min_order_amount}
|
||||||
onInput={(e) => setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })}
|
onInput={(e) =>
|
||||||
|
setForm({ ...form(), min_order_amount: Number(e.currentTarget.value) })
|
||||||
|
}
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
|
|
@ -444,9 +575,23 @@ export default function CouponPage() {
|
||||||
placeholder="Unlimited"
|
placeholder="Unlimited"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Applies To</label>
|
||||||
|
<select
|
||||||
|
value={form().applies_to}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form(), applies_to: e.currentTarget.value as "ALL" | "ROLE" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="ALL">All</option>
|
||||||
|
<option value="ROLE">Specific Roles</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">Applicable Roles</p>
|
<p style="font-size:13px;font-weight:600;margin:0 0 8px;color:#1e293b">
|
||||||
|
Applicable Roles
|
||||||
|
</p>
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
<div style="display:flex;flex-wrap:wrap;gap:8px">
|
||||||
<For each={ROLE_OPTIONS}>
|
<For each={ROLE_OPTIONS}>
|
||||||
{(role) => {
|
{(role) => {
|
||||||
|
|
@ -455,7 +600,7 @@ export default function CouponPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleRole(role)}
|
onClick={() => toggleRole(role)}
|
||||||
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? '#fdba74' : '#cbd5e1'};background:${active() ? '#fff7ed' : '#fff'};color:${active() ? '#c2410c' : '#475569'}`}
|
style={`border-radius:999px;padding:4px 14px;font-size:13px;cursor:pointer;border:1px solid ${active() ? "#fdba74" : "#cbd5e1"};background:${active() ? "#fff7ed" : "#fff"};color:${active() ? "#c2410c" : "#475569"}`}
|
||||||
>
|
>
|
||||||
{role}
|
{role}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -466,10 +611,16 @@ export default function CouponPage() {
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="btn-primary" type="submit" disabled={saving()}>
|
<button class="btn-primary" type="submit" disabled={saving()}>
|
||||||
{saving() ? 'Saving...' : (form().id ? 'Update Coupon' : 'Save Coupon')}
|
{saving() ? "Saving..." : form().id ? "Update Coupon" : "Save Coupon"}
|
||||||
</button>
|
</button>
|
||||||
<Show when={form().id}>
|
<Show when={form().id}>
|
||||||
<button type="button" class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" onClick={resetForm}>Cancel Edit</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={resetForm}
|
||||||
|
>
|
||||||
|
Cancel Edit
|
||||||
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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,37 @@ 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 [fullName, setFullName] = createSignal("");
|
||||||
const [email, setEmail] = createSignal('');
|
const [email, setEmail] = createSignal("");
|
||||||
const [employeeCode, setEmployeeCode] = 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 [roleId, setRoleId] = createSignal("");
|
||||||
const [deptId, setDeptId] = createSignal('');
|
const [deptId, setDeptId] = createSignal("");
|
||||||
const [desigId, setDesigId] = 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(
|
||||||
|
`${API}/api/admin/employees?page=${page}&per_page=100&sort=joined_desc`,
|
||||||
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: "application/json",
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
}).catch(() => null);
|
}
|
||||||
|
).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 +132,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 +148,7 @@ export default function CreateEmployeePage() {
|
||||||
try {
|
try {
|
||||||
setEmployeeCode(await fetchNextEmployeeCode());
|
setEmployeeCode(await fetchNextEmployeeCode());
|
||||||
} catch {
|
} catch {
|
||||||
setEmployeeCode('');
|
setEmployeeCode("");
|
||||||
} finally {
|
} finally {
|
||||||
setGeneratingCode(false);
|
setGeneratingCode(false);
|
||||||
}
|
}
|
||||||
|
|
@ -142,46 +157,72 @@ 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 (!fullName().trim()) {
|
||||||
if (!email().trim()) { setError('Email is required'); return; }
|
setError("Full 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 (!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: fullName().trim().split(" ")[0] || "",
|
||||||
role_id: roleId(),
|
last_name: fullName().trim().split(" ").slice(1).join(" ") || "",
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
@ -189,13 +230,16 @@ export default function CreateEmployeePage() {
|
||||||
|
|
||||||
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]">Internal Team</p>
|
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
|
||||||
|
Internal Team
|
||||||
|
</p>
|
||||||
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
|
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Add Employee</h1>
|
||||||
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Employee Management / Add Employee</p>
|
<p class="mt-1 text-[14px] text-[#6B7280]">
|
||||||
|
Dashboard / Employee Management / Add Employee
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<A
|
<A
|
||||||
href="/admin/employees"
|
href="/admin/employees"
|
||||||
|
|
@ -209,12 +253,13 @@ export default function CreateEmployeePage() {
|
||||||
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
|
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
|
||||||
<div class="border-b border-[#F3F4F6] px-6 py-4">
|
<div class="border-b border-[#F3F4F6] px-6 py-4">
|
||||||
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
|
<h2 class="text-[15px] font-semibold text-[#111827]">Employee Details</h2>
|
||||||
<p class="mt-0.5 text-[13px] text-[#6B7280]">Login credentials will be emailed to the employee automatically.</p>
|
<p class="mt-0.5 text-[13px] text-[#6B7280]">
|
||||||
|
Login credentials will be emailed to the employee automatically.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} class="p-6 space-y-5">
|
<form onSubmit={handleSave} class="p-6 space-y-5">
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
|
|
||||||
{/* Full Name */}
|
{/* Full Name */}
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
|
<label class="block text-[13px] font-medium text-[#111827] mb-1.5">
|
||||||
|
|
@ -225,7 +270,7 @@ export default function CreateEmployeePage() {
|
||||||
required
|
required
|
||||||
placeholder="e.g. Arjun Sharma"
|
placeholder="e.g. Arjun Sharma"
|
||||||
value={fullName()}
|
value={fullName()}
|
||||||
onInput={e => setFullName(e.currentTarget.value)}
|
onInput={(e) => setFullName(e.currentTarget.value)}
|
||||||
maxlength="100"
|
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]"
|
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -241,7 +286,7 @@ export default function CreateEmployeePage() {
|
||||||
required
|
required
|
||||||
placeholder="e.g. arjun@nxtgauge.com"
|
placeholder="e.g. arjun@nxtgauge.com"
|
||||||
value={email()}
|
value={email()}
|
||||||
onInput={e => setEmail(e.currentTarget.value)}
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
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>
|
||||||
|
|
@ -255,7 +300,7 @@ export default function CreateEmployeePage() {
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={employeeCode()}
|
value={employeeCode()}
|
||||||
placeholder={generatingCode() ? 'Generating...' : 'Auto generated'}
|
placeholder={generatingCode() ? "Generating..." : "Auto generated"}
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
|
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg bg-[#F9FAFB] text-[#111827] placeholder-[#9CA3AF]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,10 +308,18 @@ export default function CreateEmployeePage() {
|
||||||
{/* Login Credential Controls */}
|
{/* Login Credential Controls */}
|
||||||
<div class="col-span-2 rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] px-4 py-3">
|
<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]">
|
<label class="flex items-center gap-2 text-[13px] font-medium text-[#111827]">
|
||||||
<input type="checkbox" checked={createLoginCreds()} onChange={e => setCreateLoginCreds(e.currentTarget.checked)} class="h-4 w-4 accent-[#FF5E13]" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createLoginCreds()}
|
||||||
|
onChange={(e) => setCreateLoginCreds(e.currentTarget.checked)}
|
||||||
|
class="h-4 w-4 accent-[#FF5E13]"
|
||||||
|
/>
|
||||||
Create login credentials if this email does not exist
|
Create login credentials if this email does not exist
|
||||||
</label>
|
</label>
|
||||||
<p class="mt-1 text-[12px] text-[#6B7280]">When enabled, a user account is created with the password below and then linked as employee.</p>
|
<p class="mt-1 text-[12px] text-[#6B7280]">
|
||||||
|
When enabled, a user account is created with the password below and then linked as
|
||||||
|
employee.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={createLoginCreds()}>
|
<Show when={createLoginCreds()}>
|
||||||
|
|
@ -278,7 +331,7 @@ export default function CreateEmployeePage() {
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={loginPassword()}
|
value={loginPassword()}
|
||||||
onInput={e => setLoginPassword(e.currentTarget.value)}
|
onInput={(e) => setLoginPassword(e.currentTarget.value)}
|
||||||
placeholder="Minimum 8 characters"
|
placeholder="Minimum 8 characters"
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -290,7 +343,7 @@ export default function CreateEmployeePage() {
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmLoginPassword()}
|
value={confirmLoginPassword()}
|
||||||
onInput={e => setConfirmLoginPassword(e.currentTarget.value)}
|
onInput={(e) => setConfirmLoginPassword(e.currentTarget.value)}
|
||||||
placeholder="Repeat password"
|
placeholder="Repeat password"
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] text-[#111827] placeholder-[#9CA3AF]"
|
||||||
/>
|
/>
|
||||||
|
|
@ -305,11 +358,11 @@ export default function CreateEmployeePage() {
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={roleId()}
|
value={roleId()}
|
||||||
onChange={e => setRoleId(e.currentTarget.value)}
|
onChange={(e) => setRoleId(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
|
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>
|
<option value="">Select role…</option>
|
||||||
<For each={roles() ?? []}>{r => <option value={r.id}>{r.name}</option>}</For>
|
<For each={roles() ?? []}>{(r) => <option value={r.id}>{r.name}</option>}</For>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -320,11 +373,11 @@ export default function CreateEmployeePage() {
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={deptId()}
|
value={deptId()}
|
||||||
onChange={e => setDeptId(e.currentTarget.value)}
|
onChange={(e) => setDeptId(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
|
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>
|
<option value="">Select department…</option>
|
||||||
<For each={depts() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
|
<For each={depts() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,14 +388,13 @@ export default function CreateEmployeePage() {
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={desigId()}
|
value={desigId()}
|
||||||
onChange={e => setDesigId(e.currentTarget.value)}
|
onChange={(e) => setDesigId(e.currentTarget.value)}
|
||||||
class="w-full px-3 py-2.5 text-[13px] border border-[#E5E7EB] rounded-lg outline-none focus:border-[#FF5E13] focus:ring-1 focus:ring-[#FF5E13] bg-white text-[#111827]"
|
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>
|
<option value="">Select designation…</option>
|
||||||
<For each={desigs() ?? []}>{d => <option value={d.id}>{d.name}</option>}</For>
|
<For each={desigs() ?? []}>{(d) => <option value={d.id}>{d.name}</option>}</For>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info note */}
|
{/* Info note */}
|
||||||
|
|
@ -352,7 +404,9 @@ export default function CreateEmployeePage() {
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error() && (
|
{error() && (
|
||||||
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">{error()}</div>
|
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
@ -368,12 +422,11 @@ export default function CreateEmployeePage() {
|
||||||
disabled={saving()}
|
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"
|
class="h-[40px] rounded-xl bg-[#FF5E13] px-6 text-[13px] font-semibold text-white hover:bg-[#e04d0a] transition-colors disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{saving() ? 'Creating…' : 'Add Employee'}
|
{saving() ? "Creating…" : "Add Employee"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,8 +42,11 @@ 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);
|
||||||
|
|
@ -53,18 +56,28 @@ export default function JobDetailPage() {
|
||||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
<div 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"><p class="notice">Loading job...</p></div>
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<p class="notice">Loading job...</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!job.loading && !job()}>
|
<Show when={!job.loading && !job()}>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Job not found.</p></div>
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<p class="notice">Job not found.</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={job()}>
|
<Show when={job()}>
|
||||||
|
|
@ -72,11 +85,11 @@ export default function JobDetailPage() {
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Title</p>
|
<p class="hint">Title</p>
|
||||||
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || '—'}</p>
|
<p style="margin:6px 0 0;font-weight:700;color:#0f172a">{job()!.title || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Status</p>
|
<p class="hint">Status</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{job()!.status || '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">{job()!.status || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Client</p>
|
<p class="hint">Client</p>
|
||||||
|
|
@ -88,28 +101,36 @@ export default function JobDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Rate</p>
|
<p class="hint">Rate</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{rateMin() != null ? `₹${rateMin()} - ₹${rateMax() ?? rateMin()}` : '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">
|
||||||
|
{rateMin() != null ? `₹${rateMin()} - ₹${rateMax() ?? rateMin()}` : "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Location</p>
|
<p class="hint">Location</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{job()!.location || '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">{job()!.location || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Availability</p>
|
<p class="hint">Availability</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{job()!.availability || '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">{job()!.availability || "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="hint">Duration</p>
|
<p class="hint">Duration</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{duration() != null ? `${duration()} days` : '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">
|
||||||
|
{duration() != null ? `${duration()} days` : "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="grid-column:1/-1">
|
<div style="grid-column:1/-1">
|
||||||
<p class="hint">Required Skills</p>
|
<p class="hint">Required Skills</p>
|
||||||
<p style="margin:6px 0 0;color:#334155">{skills().length > 0 ? skills().join(', ') : '—'}</p>
|
<p style="margin:6px 0 0;color:#334155">
|
||||||
|
{skills().length > 0 ? skills().join(", ") : "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:18px">
|
<div style="margin-top:18px">
|
||||||
<p class="hint">Description</p>
|
<p class="hint">Description</p>
|
||||||
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">{job()!.description || '—'}</p>
|
<p style="margin:6px 0 0;color:#334155;white-space:pre-wrap">
|
||||||
|
{job()!.description || "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
|
||||||
|
|
@ -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,33 +69,34 @@ 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">
|
||||||
|
|
@ -105,17 +106,31 @@ export default function KbArticleEditPage() {
|
||||||
<p class="text-sm text-gray-500 mt-0.5">Update article metadata, status, and content.</p>
|
<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">
|
||||||
|
<p class="py-10 text-center text-sm text-slate-400">Loading article…</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!article.loading && !article()}>
|
<Show when={!article.loading && !article()}>
|
||||||
<div class="table-card"><p class="py-10 text-center text-sm text-slate-400">Article not found.</p></div>
|
<div class="table-card">
|
||||||
|
<p class="py-10 text-center text-sm text-slate-400">Article not found.</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={article() && loaded()}>
|
<Show when={article() && loaded()}>
|
||||||
|
|
@ -123,36 +138,60 @@ export default function KbArticleEditPage() {
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Title</label>
|
<label class={labelCls}>Title</label>
|
||||||
<input class={inputCls} value={title()} onInput={(e) => setTitle(e.currentTarget.value)} required />
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
value={title()}
|
||||||
|
onInput={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Slug</label>
|
<label class={labelCls}>Slug</label>
|
||||||
<input class={inputCls} value={slug()} onInput={(e) => setSlug(e.currentTarget.value)} />
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
value={slug()}
|
||||||
|
onInput={(e) => setSlug(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Category ID</label>
|
<label class={labelCls}>Category ID</label>
|
||||||
<input class={inputCls} value={categoryId()} onInput={(e) => setCategoryId(e.currentTarget.value)} />
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
value={categoryId()}
|
||||||
|
onInput={(e) => setCategoryId(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class={labelCls}>Status</label>
|
<label class={labelCls}>Status</label>
|
||||||
<select class={inputCls} value={status()} onChange={(e) => setStatus(e.currentTarget.value)}>
|
<select
|
||||||
|
class={inputCls}
|
||||||
|
value={status()}
|
||||||
|
onChange={(e) => setStatus(e.currentTarget.value)}
|
||||||
|
>
|
||||||
<option value="DRAFT">DRAFT</option>
|
<option value="DRAFT">DRAFT</option>
|
||||||
<option value="PUBLISHED">PUBLISHED</option>
|
<option value="PUBLISHED">PUBLISHED</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
<label class={labelCls}>Content</label>
|
<label class={labelCls}>Content</label>
|
||||||
<textarea rows="16" class={inputCls} value={content()} onInput={(e) => setContent(e.currentTarget.value)} />
|
<textarea
|
||||||
|
rows="16"
|
||||||
|
class={inputCls}
|
||||||
|
value={content()}
|
||||||
|
onInput={(e) => setContent(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={error()}>
|
<Show when={error()}>
|
||||||
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error()}</p>
|
<p class="mt-3 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error()}
|
||||||
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
|
<div class="mt-6 flex justify-end border-t border-gray-100 pt-5">
|
||||||
<button class="btn-primary" type="submit" disabled={saving()}>
|
<button class="btn-primary" type="submit" disabled={saving()}>
|
||||||
{saving() ? 'Saving…' : 'Save Article'}
|
{saving() ? "Saving…" : "Save Article"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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,50 +165,79 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -189,35 +245,42 @@ export default function CreateInternalRolePage() {
|
||||||
|
|
||||||
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]">Access Control</p>
|
<p class="text-[12px] font-semibold uppercase tracking-widest text-[#FF5E13]">
|
||||||
<h1 class="mt-1 text-[28px] font-bold leading-tight text-[#111827]">Create Internal Role</h1>
|
Access Control
|
||||||
<p class="mt-1 text-[14px] text-[#6B7280]">Dashboard / Internal Role Management / Create Role</p>
|
</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>
|
</div>
|
||||||
<A href="/admin/roles" class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors">
|
<A
|
||||||
|
href="/admin/roles"
|
||||||
|
class="inline-flex items-center gap-2 rounded-xl border border-[#E5E7EB] bg-white px-4 py-2.5 text-[13px] font-semibold text-[#374151] hover:bg-[#F9FAFB] transition-colors"
|
||||||
|
>
|
||||||
← Back to Roles
|
← Back to Roles
|
||||||
</A>
|
</A>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
|
<div class="rounded-2xl border border-[#E5E7EB] bg-white shadow-sm overflow-hidden">
|
||||||
|
|
||||||
{/* Sub-tabs */}
|
{/* Sub-tabs */}
|
||||||
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
|
<div class="flex items-center gap-1 border-b border-[#F3F4F6] px-6">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
{ key: 'general', label: 'General Information' },
|
{ key: "general", label: "General Information" },
|
||||||
{ key: 'module', label: 'Module Access' },
|
{ key: "module", label: "Module Access" },
|
||||||
{ key: 'settings', label: 'Role Settings' },
|
{ key: "settings", label: "Role Settings" },
|
||||||
] as const
|
] as const
|
||||||
).map((t) => (
|
).map((t) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSubTab(t.key)}
|
onClick={() => setSubTab(t.key)}
|
||||||
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
|
class={`relative px-4 py-4 text-[13px] font-semibold transition-colors ${
|
||||||
subTab() === t.key ? 'text-[#111827]' : 'text-[#9CA3AF] hover:text-[#6B7280]'
|
subTab() === t.key ? "text-[#111827]" : "text-[#9CA3AF] hover:text-[#6B7280]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
|
|
@ -236,7 +299,7 @@ export default function CreateInternalRolePage() {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Tab: General Information ── */}
|
{/* ── Tab: General Information ── */}
|
||||||
<Show when={subTab() === 'general'}>
|
<Show when={subTab() === "general"}>
|
||||||
<div class="p-6 space-y-5">
|
<div class="p-6 space-y-5">
|
||||||
<div class="grid grid-cols-2 gap-5">
|
<div class="grid grid-cols-2 gap-5">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -296,7 +359,7 @@ export default function CreateInternalRolePage() {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Tab: Module Access ── */}
|
{/* ── Tab: Module Access ── */}
|
||||||
<Show when={subTab() === 'module'}>
|
<Show when={subTab() === "module"}>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
|
<p class="text-[13px] text-[rgba(13,13,42,0.5)] mb-4">
|
||||||
Configure module access permissions for this role.
|
Configure module access permissions for this role.
|
||||||
|
|
@ -316,7 +379,7 @@ export default function CreateInternalRolePage() {
|
||||||
onClick={() => (allSelected() ? deselectAll() : selectAll())}
|
onClick={() => (allSelected() ? deselectAll() : selectAll())}
|
||||||
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
|
class="text-[11px] font-semibold text-[#FF5E13] hover:text-[#e04d0a] transition-colors whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{allSelected() ? 'Deselect All' : 'Select All'}
|
{allSelected() ? "Deselect All" : "Select All"}
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -324,7 +387,10 @@ export default function CreateInternalRolePage() {
|
||||||
<tbody class="divide-y divide-[#e5e7eb]">
|
<tbody class="divide-y divide-[#e5e7eb]">
|
||||||
<Show when={permissions.loading}>
|
<Show when={permissions.loading}>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]">
|
<td
|
||||||
|
colspan="6"
|
||||||
|
class="px-5 py-6 text-center text-[13px] text-[rgba(13,13,42,0.4)]"
|
||||||
|
>
|
||||||
Loading modules…
|
Loading modules…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -334,7 +400,9 @@ export default function CreateInternalRolePage() {
|
||||||
const perms = () => permsByModule()[module] ?? [];
|
const perms = () => permsByModule()[module] ?? [];
|
||||||
const byAction = () => {
|
const byAction = () => {
|
||||||
const m: Record<string, Permission> = {};
|
const m: Record<string, Permission> = {};
|
||||||
perms().forEach((p) => { m[p.action] = p; });
|
perms().forEach((p) => {
|
||||||
|
m[p.action] = p;
|
||||||
|
});
|
||||||
return m;
|
return m;
|
||||||
};
|
};
|
||||||
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
|
const rowAllSelected = () => perms().every((p) => selectedKeys().has(p.key));
|
||||||
|
|
@ -380,7 +448,7 @@ export default function CreateInternalRolePage() {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* ── Tab: Role Settings ── */}
|
{/* ── Tab: Role Settings ── */}
|
||||||
<Show when={subTab() === 'settings'}>
|
<Show when={subTab() === "settings"}>
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
{/* Status toggle */}
|
{/* Status toggle */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -391,8 +459,8 @@ export default function CreateInternalRolePage() {
|
||||||
onClick={() => setIsActive(true)}
|
onClick={() => setIsActive(true)}
|
||||||
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
||||||
isActive()
|
isActive()
|
||||||
? 'border-[#059669] bg-[#ECFDF5] text-[#059669]'
|
? "border-[#059669] bg-[#ECFDF5] text-[#059669]"
|
||||||
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
|
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Active
|
Active
|
||||||
|
|
@ -402,8 +470,8 @@ export default function CreateInternalRolePage() {
|
||||||
onClick={() => setIsActive(false)}
|
onClick={() => setIsActive(false)}
|
||||||
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
class={`h-[38px] rounded-xl border px-5 text-[13px] font-semibold transition-colors ${
|
||||||
!isActive()
|
!isActive()
|
||||||
? 'border-[#6B7280] bg-[#F3F4F6] text-[#374151]'
|
? "border-[#6B7280] bg-[#F3F4F6] text-[#374151]"
|
||||||
: 'border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
|
: "border-[#E5E7EB] bg-white text-[#6B7280] hover:bg-[#F9FAFB]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Inactive
|
Inactive
|
||||||
|
|
@ -443,11 +511,10 @@ export default function CreateInternalRolePage() {
|
||||||
disabled={saving()}
|
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"
|
class="h-[40px] rounded-xl bg-[#0D0D2A] px-6 text-[13px] font-semibold text-white hover:bg-[#1a1a3e] transition-colors disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{saving() ? 'Creating…' : 'Create Role'}
|
{saving() ? "Creating…" : "Create Role"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -471,12 +538,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>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
import { createResource, createSignal, createMemo, Show, For } from 'solid-js';
|
import { createResource, createSignal, createMemo, Show, For } from "solid-js";
|
||||||
import { A } from '@solidjs/router';
|
import { A } from "@solidjs/router";
|
||||||
|
|
||||||
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(contentType = false): Record<string, string> {
|
function authHeaders(contentType = false): Record<string, string> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
return {
|
return {
|
||||||
Accept: 'application/json',
|
Accept: "application/json",
|
||||||
...(contentType ? { 'Content-Type': 'application/json' } : {}),
|
...(contentType ? { "Content-Type": "application/json" } : {}),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -22,9 +22,14 @@ type SupportCase = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: 'platform_issue' | 'customer_query' | 'professional_query' | 'billing_issue' | 'lead_dispute';
|
type:
|
||||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
| "platform_issue"
|
||||||
status: 'new' | 'in_progress' | 'waiting_for_user' | 'resolved' | 'closed';
|
| "customer_query"
|
||||||
|
| "professional_query"
|
||||||
|
| "billing_issue"
|
||||||
|
| "lead_dispute";
|
||||||
|
priority: "low" | "medium" | "high" | "critical";
|
||||||
|
status: "new" | "in_progress" | "waiting_for_user" | "resolved" | "closed";
|
||||||
requesterName?: string;
|
requesterName?: string;
|
||||||
requesterEmail?: string;
|
requesterEmail?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -37,55 +42,68 @@ type AssigneeOption = {
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS: SupportCase['status'][] = ['new', 'in_progress', 'waiting_for_user', 'resolved', 'closed'];
|
const STATUS_OPTIONS: SupportCase["status"][] = [
|
||||||
const TYPE_OPTIONS: SupportCase['type'][] = ['platform_issue', 'customer_query', 'professional_query', 'billing_issue', 'lead_dispute'];
|
"new",
|
||||||
const PRIORITY_OPTIONS: SupportCase['priority'][] = ['low', 'medium', 'high', 'critical'];
|
"in_progress",
|
||||||
|
"waiting_for_user",
|
||||||
|
"resolved",
|
||||||
|
"closed",
|
||||||
|
];
|
||||||
|
const TYPE_OPTIONS: SupportCase["type"][] = [
|
||||||
|
"platform_issue",
|
||||||
|
"customer_query",
|
||||||
|
"professional_query",
|
||||||
|
"billing_issue",
|
||||||
|
"lead_dispute",
|
||||||
|
];
|
||||||
|
const PRIORITY_OPTIONS: SupportCase["priority"][] = ["low", "medium", "high", "critical"];
|
||||||
|
|
||||||
function formatValue(input: string): string {
|
function formatValue(input: string): string {
|
||||||
return input.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
return input.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
function typeBadgeStyle(type: string): string {
|
function typeBadgeStyle(type: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
platform_issue: 'background:#dbeafe;color:#1d4ed8',
|
platform_issue: "background:#dbeafe;color:#1d4ed8",
|
||||||
customer_query: 'background:#dcfce7;color:#15803d',
|
customer_query: "background:#dcfce7;color:#15803d",
|
||||||
billing_issue: 'background:#ffedd5;color:#c2410c',
|
billing_issue: "background:#ffedd5;color:#c2410c",
|
||||||
lead_dispute: 'background:#fee2e2;color:#b91c1c',
|
lead_dispute: "background:#fee2e2;color:#b91c1c",
|
||||||
professional_query: 'background:#f3e8ff;color:#7e22ce',
|
professional_query: "background:#f3e8ff;color:#7e22ce",
|
||||||
};
|
};
|
||||||
return map[type] || 'background:#f1f5f9;color:#475569';
|
return map[type] || "background:#f1f5f9;color:#475569";
|
||||||
}
|
}
|
||||||
|
|
||||||
function priorityBadgeStyle(priority: string): string {
|
function priorityBadgeStyle(priority: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
low: 'background:#f1f5f9;color:#475569',
|
low: "background:#f1f5f9;color:#475569",
|
||||||
medium: 'background:#dbeafe;color:#1d4ed8',
|
medium: "background:#dbeafe;color:#1d4ed8",
|
||||||
high: 'background:#ffedd5;color:#c2410c',
|
high: "background:#ffedd5;color:#c2410c",
|
||||||
critical: 'background:#fee2e2;color:#b91c1c',
|
critical: "background:#fee2e2;color:#b91c1c",
|
||||||
};
|
};
|
||||||
return map[priority] || 'background:#f1f5f9;color:#475569';
|
return map[priority] || "background:#f1f5f9;color:#475569";
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeStyle(status: string): string {
|
function statusBadgeStyle(status: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
new: 'background:#dbeafe;color:#1d4ed8',
|
new: "background:#dbeafe;color:#1d4ed8",
|
||||||
in_progress: 'background:#ffedd5;color:#c2410c',
|
in_progress: "background:#ffedd5;color:#c2410c",
|
||||||
waiting_for_user: 'background:#fef9c3;color:#a16207',
|
waiting_for_user: "background:#fef9c3;color:#a16207",
|
||||||
resolved: 'background:#dcfce7;color:#15803d',
|
resolved: "background:#dcfce7;color:#15803d",
|
||||||
closed: 'background:#f1f5f9;color:#475569',
|
closed: "background:#f1f5f9;color:#475569",
|
||||||
};
|
};
|
||||||
return map[status] || 'background:#f1f5f9;color:#475569';
|
return map[status] || "background:#f1f5f9;color:#475569";
|
||||||
}
|
}
|
||||||
|
|
||||||
const BADGE_STYLE = 'display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600';
|
const BADGE_STYLE =
|
||||||
|
"display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:600";
|
||||||
|
|
||||||
async function loadAllCases(): Promise<SupportCase[]> {
|
async function loadAllCases(): Promise<SupportCase[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed');
|
if (!res.ok) throw new Error("Failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
|
return Array.isArray(data.cases) ? data.cases : Array.isArray(data) ? data : [];
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -95,29 +113,31 @@ async function loadAllCases(): Promise<SupportCase[]> {
|
||||||
|
|
||||||
async function loadAssignees(): Promise<AssigneeOption[]> {
|
async function loadAssignees(): Promise<AssigneeOption[]> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: '1', per_page: '200', sort: 'joined_desc' });
|
const params = new URLSearchParams({ page: "1", per_page: "200", sort: "joined_desc" });
|
||||||
const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
|
const res = await fetch(`${API}/api/admin/employees?${params.toString()}`, {
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Failed');
|
if (!res.ok) throw new Error("Failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
const raw = Array.isArray(data?.items) ? data.items : Array.isArray(data) ? data : [];
|
||||||
return raw.map((item: any) => ({
|
return raw
|
||||||
id: String(item.id ?? ''),
|
.map((item: any) => ({
|
||||||
name: String(item.name ?? item.full_name ?? item.email ?? 'Unknown'),
|
id: String(item.id ?? ""),
|
||||||
|
name: String(item.name ?? item.full_name ?? item.email ?? "Unknown"),
|
||||||
email: item.email ? String(item.email) : undefined,
|
email: item.email ? String(item.email) : undefined,
|
||||||
})).filter((item: AssigneeOption) => Boolean(item.id));
|
}))
|
||||||
|
.filter((item: AssigneeOption) => Boolean(item.id));
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SupportPage() {
|
export default function SupportPage() {
|
||||||
const [activeTab, setActiveTab] = createSignal<'queue' | 'create'>('queue');
|
const [activeTab, setActiveTab] = createSignal<"queue" | "create">("queue");
|
||||||
const [statusFilter, setStatusFilter] = createSignal<'all' | SupportCase['status']>('all');
|
const [statusFilter, setStatusFilter] = createSignal<"all" | SupportCase["status"]>("all");
|
||||||
const [search, setSearch] = createSignal('');
|
const [search, setSearch] = createSignal("");
|
||||||
const [sortBy, setSortBy] = createSignal<'newest' | 'oldest' | 'priority'>('newest');
|
const [sortBy, setSortBy] = createSignal<"newest" | "oldest" | "priority">("newest");
|
||||||
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
const [sortMenuOpen, setSortMenuOpen] = createSignal(false);
|
||||||
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
const [filterMenuOpen, setFilterMenuOpen] = createSignal(false);
|
||||||
const [refetchKey, setRefetchKey] = createSignal(0);
|
const [refetchKey, setRefetchKey] = createSignal(0);
|
||||||
|
|
@ -131,21 +151,39 @@ export default function SupportPage() {
|
||||||
let all = cases() ?? [];
|
let all = cases() ?? [];
|
||||||
const q = search().toLowerCase().trim();
|
const q = search().toLowerCase().trim();
|
||||||
if (q) {
|
if (q) {
|
||||||
all = all.filter((c) =>
|
all = all.filter(
|
||||||
String(c.title || '').toLowerCase().includes(q)
|
(c) =>
|
||||||
|| String(c.description || '').toLowerCase().includes(q)
|
String(c.title || "")
|
||||||
|| String(c.requesterName || '').toLowerCase().includes(q)
|
.toLowerCase()
|
||||||
|| String(c.requesterEmail || '').toLowerCase().includes(q)
|
.includes(q) ||
|
||||||
|| String(c.type || '').toLowerCase().includes(q)
|
String(c.description || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q) ||
|
||||||
|
String(c.requesterName || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q) ||
|
||||||
|
String(c.requesterEmail || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q) ||
|
||||||
|
String(c.type || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const sf = statusFilter();
|
const sf = statusFilter();
|
||||||
if (sf !== 'all') all = all.filter((c) => c.status === sf);
|
if (sf !== "all") all = all.filter((c) => c.status === sf);
|
||||||
const priorityRank: Record<SupportCase['priority'], number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
const priorityRank: Record<SupportCase["priority"], number> = {
|
||||||
|
critical: 4,
|
||||||
|
high: 3,
|
||||||
|
medium: 2,
|
||||||
|
low: 1,
|
||||||
|
};
|
||||||
const sorted = [...all];
|
const sorted = [...all];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
if (sortBy() === 'oldest') return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
if (sortBy() === "oldest")
|
||||||
if (sortBy() === 'priority') return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
return new Date(a.createdAt || 0).getTime() - new Date(b.createdAt || 0).getTime();
|
||||||
|
if (sortBy() === "priority")
|
||||||
|
return (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0);
|
||||||
return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
|
return new Date(b.createdAt || 0).getTime() - new Date(a.createdAt || 0).getTime();
|
||||||
});
|
});
|
||||||
return sorted;
|
return sorted;
|
||||||
|
|
@ -154,49 +192,49 @@ export default function SupportPage() {
|
||||||
const stats = createMemo(() => {
|
const stats = createMemo(() => {
|
||||||
const all = cases() ?? [];
|
const all = cases() ?? [];
|
||||||
return {
|
return {
|
||||||
newCount: all.filter((c) => c.status === 'new').length,
|
newCount: all.filter((c) => c.status === "new").length,
|
||||||
inProgressCount: all.filter((c) => c.status === 'in_progress').length,
|
inProgressCount: all.filter((c) => c.status === "in_progress").length,
|
||||||
waitingCount: all.filter((c) => c.status === 'waiting_for_user').length,
|
waitingCount: all.filter((c) => c.status === "waiting_for_user").length,
|
||||||
total: all.length,
|
total: all.length,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create Case form state
|
// Create Case form state
|
||||||
const [fTitle, setFTitle] = createSignal('');
|
const [fTitle, setFTitle] = createSignal("");
|
||||||
const [fDesc, setFDesc] = createSignal('');
|
const [fDesc, setFDesc] = createSignal("");
|
||||||
const [fType, setFType] = createSignal<SupportCase['type']>('customer_query');
|
const [fType, setFType] = createSignal<SupportCase["type"]>("customer_query");
|
||||||
const [fPriority, setFPriority] = createSignal<SupportCase['priority']>('medium');
|
const [fPriority, setFPriority] = createSignal<SupportCase["priority"]>("medium");
|
||||||
const [fRequesterName, setFRequesterName] = createSignal('');
|
const [fRequesterName, setFRequesterName] = createSignal("");
|
||||||
const [fRequesterEmail, setFRequesterEmail] = createSignal('');
|
const [fRequesterEmail, setFRequesterEmail] = createSignal("");
|
||||||
const [fAssignedTo, setFAssignedTo] = createSignal('');
|
const [fAssignedTo, setFAssignedTo] = createSignal("");
|
||||||
const [createLoading, setCreateLoading] = createSignal(false);
|
const [createLoading, setCreateLoading] = createSignal(false);
|
||||||
const [createSuccess, setCreateSuccess] = createSignal('');
|
const [createSuccess, setCreateSuccess] = createSignal("");
|
||||||
const [createError, setCreateError] = createSignal('');
|
const [createError, setCreateError] = createSignal("");
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFTitle('');
|
setFTitle("");
|
||||||
setFDesc('');
|
setFDesc("");
|
||||||
setFType('customer_query');
|
setFType("customer_query");
|
||||||
setFPriority('medium');
|
setFPriority("medium");
|
||||||
setFRequesterName('');
|
setFRequesterName("");
|
||||||
setFRequesterEmail('');
|
setFRequesterEmail("");
|
||||||
setFAssignedTo('');
|
setFAssignedTo("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: Event) => {
|
const handleCreate = async (e: Event) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
setCreateSuccess('');
|
setCreateSuccess("");
|
||||||
setCreateError('');
|
setCreateError("");
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API}/api/admin/support-cases`, {
|
const res = await fetch(`${API}/api/admin/support-cases`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: authHeaders(true),
|
headers: authHeaders(true),
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: fTitle(),
|
title: fTitle(),
|
||||||
description: fDesc(),
|
description: fDesc(),
|
||||||
type: fType(),
|
category: fType(),
|
||||||
priority: fPriority(),
|
priority: fPriority(),
|
||||||
requesterName: fRequesterName(),
|
requesterName: fRequesterName(),
|
||||||
requesterEmail: fRequesterEmail(),
|
requesterEmail: fRequesterEmail(),
|
||||||
|
|
@ -204,52 +242,54 @@ export default function SupportPage() {
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const d = await res.json().catch(() => ({}));
|
const d = await res.json().catch(() => ({}));
|
||||||
throw new Error((d as any).message || 'Failed to create case');
|
throw new Error((d as any).message || "Failed to create case");
|
||||||
}
|
}
|
||||||
const created = await res.json().catch(() => ({}));
|
const created = await res.json().catch(() => ({}));
|
||||||
const createdId = String((created as any)?.id || '');
|
const createdId = String((created as any)?.id || "");
|
||||||
if (createdId && fAssignedTo()) {
|
if (createdId && fAssignedTo()) {
|
||||||
await fetch(`${API}/api/admin/support-cases/${createdId}`, {
|
await fetch(`${API}/api/admin/support-cases/${createdId}`, {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: authHeaders(true),
|
headers: authHeaders(true),
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
body: JSON.stringify({ assigned_to: fAssignedTo() }),
|
body: JSON.stringify({ assigned_to: fAssignedTo() }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setCreateSuccess('Case created!');
|
setCreateSuccess("Case created!");
|
||||||
resetForm();
|
resetForm();
|
||||||
refetch();
|
refetch();
|
||||||
setActiveTab('queue');
|
setActiveTab("queue");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setCreateError(err.message || 'Failed to create case');
|
setCreateError(err.message || "Failed to create case");
|
||||||
} finally {
|
} finally {
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'New', getValue: () => stats().newCount },
|
{ label: "New", getValue: () => stats().newCount },
|
||||||
{ label: 'In Progress', getValue: () => stats().inProgressCount },
|
{ label: "In Progress", getValue: () => stats().inProgressCount },
|
||||||
{ label: 'Waiting', getValue: () => stats().waitingCount },
|
{ label: "Waiting", getValue: () => stats().waitingCount },
|
||||||
{ label: 'Total', getValue: () => stats().total },
|
{ label: "Total", getValue: () => stats().total },
|
||||||
];
|
];
|
||||||
|
|
||||||
const exportCsv = () => {
|
const exportCsv = () => {
|
||||||
const headers = ['Issue', 'Type', 'Priority', 'Status', 'Requester', 'Updated'];
|
const headers = ["Issue", "Type", "Priority", "Status", "Requester", "Updated"];
|
||||||
const rows = filteredCases().map((item) => [
|
const rows = filteredCases().map((item) => [
|
||||||
item.title,
|
item.title,
|
||||||
item.type,
|
item.type,
|
||||||
item.priority,
|
item.priority,
|
||||||
item.status,
|
item.status,
|
||||||
item.requesterEmail || item.requesterName || '—',
|
item.requesterEmail || item.requesterName || "—",
|
||||||
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—',
|
item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—",
|
||||||
]);
|
]);
|
||||||
const csv = [headers, ...rows].map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
|
const csv = [headers, ...rows]
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
.map((line) => line.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","))
|
||||||
|
.join("\n");
|
||||||
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = 'support-management.csv';
|
link.download = "support-management.csv";
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
@ -265,19 +305,23 @@ export default function SupportPage() {
|
||||||
<div class="bg-white border-b border-gray-200 px-6 flex items-center gap-8 sticky top-0 z-10">
|
<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() === 'queue'
|
class={
|
||||||
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
|
activeTab() === "queue"
|
||||||
: '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"
|
||||||
onClick={() => setActiveTab('queue')}
|
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
}
|
||||||
|
onClick={() => setActiveTab("queue")}
|
||||||
>
|
>
|
||||||
Support Queue
|
Support Queue
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={activeTab() === 'create'
|
class={
|
||||||
? 'py-3 border-b-2 border-orange-500 text-orange-600 text-sm font-medium'
|
activeTab() === "create"
|
||||||
: '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"
|
||||||
onClick={() => setActiveTab('create')}
|
: "py-3 border-b-2 border-transparent text-gray-500 hover:text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
}
|
||||||
|
onClick={() => setActiveTab("create")}
|
||||||
>
|
>
|
||||||
Create Case
|
Create Case
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -297,7 +341,7 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Support Queue Tab */}
|
{/* Support Queue Tab */}
|
||||||
<Show when={activeTab() === 'queue'}>
|
<Show when={activeTab() === "queue"}>
|
||||||
<div style="position:relative;margin-top:1.5rem;margin-left:-24px;margin-right:-24px;border-radius:0;border-left:none;border-right:none;overflow:visible;border-top:1px solid #E5E7EB;border-bottom:1px solid #E5E7EB;background:white;box-shadow:0 1px 3px rgba(0,0,0,0.06)">
|
<div style="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
|
||||||
|
|
@ -308,42 +352,123 @@ export default function SupportPage() {
|
||||||
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
style="height:34px;flex:1;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:13px;color:#111827;outline:none"
|
||||||
/>
|
/>
|
||||||
<div style="position:relative;">
|
<div style="position:relative;">
|
||||||
<button type="button" onClick={() => { setSortMenuOpen((v) => !v); setFilterMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
|
<button
|
||||||
<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>
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSortMenuOpen((v) => !v);
|
||||||
|
setFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M7 4v13" />
|
||||||
|
<path d="m3 13 4 4 4-4" />
|
||||||
|
<path d="M17 20V7" />
|
||||||
|
<path d="m21 11-4-4-4 4" />
|
||||||
|
</svg>
|
||||||
Sort
|
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: 'priority', label: 'Priority High-Low' },
|
{ key: "newest", label: "Newest First" },
|
||||||
] as { key: 'newest' | 'oldest' | 'priority'; label: string }[]}>
|
{ key: "oldest", label: "Oldest First" },
|
||||||
|
{ key: "priority", label: "Priority High-Low" },
|
||||||
|
] as { key: "newest" | "oldest" | "priority"; 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'}`}>{item.label}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSortBy(item.key);
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
}}
|
||||||
|
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${sortBy() === item.key ? "#FF5E13" : "#374151"};background:${sortBy() === item.key ? "#FFF1EB" : "transparent"}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div style="position:relative;">
|
<div style="position:relative;">
|
||||||
<button type="button" onClick={() => { setFilterMenuOpen((v) => !v); setSortMenuOpen(false); }} style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer">
|
<button
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 5h18M6 12h12M10 19h4"/></svg>
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilterMenuOpen((v) => !v);
|
||||||
|
setSortMenuOpen(false);
|
||||||
|
}}
|
||||||
|
style="display:inline-flex;height:34px;align-items:center;gap:6px;border-radius:8px;border:1px solid #E5E7EB;background:white;padding:0 12px;font-size:12px;font-weight:500;color:#374151;cursor:pointer"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M3 5h18M6 12h12M10 19h4" />
|
||||||
|
</svg>
|
||||||
Filters
|
Filters
|
||||||
</button>
|
</button>
|
||||||
<Show when={filterMenuOpen()}>
|
<Show when={filterMenuOpen()}>
|
||||||
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:220px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
<div style="position:absolute;right:0;top:38px;z-index:40;min-width:220px;border:1px solid #E5E7EB;border-radius:12px;background:#fff;box-shadow:0 12px 24px rgba(2,6,23,0.12);padding:8px">
|
||||||
<button type="button" onClick={() => { setStatusFilter('all'); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === 'all' ? '#FF5E13' : '#374151'};background:${statusFilter() === 'all' ? '#FFF1EB' : 'transparent'}`}>All statuses</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter("all");
|
||||||
|
setFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === "all" ? "#FF5E13" : "#374151"};background:${statusFilter() === "all" ? "#FFF1EB" : "transparent"}`}
|
||||||
|
>
|
||||||
|
All statuses
|
||||||
|
</button>
|
||||||
<For each={STATUS_OPTIONS}>
|
<For each={STATUS_OPTIONS}>
|
||||||
{(s) => (
|
{(s) => (
|
||||||
<button type="button" onClick={() => { setStatusFilter(s); setFilterMenuOpen(false); }} style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? '#FF5E13' : '#374151'};background:${statusFilter() === s ? '#FFF1EB' : 'transparent'}`}>{formatValue(s)}</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter(s);
|
||||||
|
setFilterMenuOpen(false);
|
||||||
|
}}
|
||||||
|
style={`display:block;width:100%;border-radius:8px;padding:8px 12px;text-align:left;font-size:13px;background:none;border:none;cursor:pointer;color:${statusFilter() === s ? "#FF5E13" : "#374151"};background:${statusFilter() === s ? "#FFF1EB" : "transparent"}`}
|
||||||
|
>
|
||||||
|
{formatValue(s)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
@ -364,13 +489,25 @@ export default function SupportPage() {
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<Show when={cases.loading}>
|
<Show when={cases.loading}>
|
||||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#64748b">Loading...</td></tr>
|
<tr>
|
||||||
|
<td colspan="7" style="text-align:center;padding:32px;color:#64748b">
|
||||||
|
Loading...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!cases.loading && cases.error}>
|
<Show when={!cases.loading && cases.error}>
|
||||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">Failed to load cases.</td></tr>
|
<tr>
|
||||||
|
<td colspan="7" style="text-align:center;padding:32px;color:#b91c1c">
|
||||||
|
Failed to load cases.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}>
|
<Show when={!cases.loading && !cases.error && filteredCases().length === 0}>
|
||||||
<tr><td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">No support cases found.</td></tr>
|
<tr>
|
||||||
|
<td colspan="7" style="text-align:center;padding:32px;color:#94a3b8">
|
||||||
|
No support cases found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||||||
<For each={filteredCases()}>
|
<For each={filteredCases()}>
|
||||||
|
|
@ -378,27 +515,42 @@ export default function SupportPage() {
|
||||||
<tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}>
|
<tr class="hover:bg-slate-50" style="cursor:pointer" onClick={() => {}}>
|
||||||
<td>
|
<td>
|
||||||
<div class="font-semibold text-slate-900">{item.title}</div>
|
<div class="font-semibold text-slate-900">{item.title}</div>
|
||||||
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{item.description}</div>
|
<div style="font-size:12px;color:#64748b;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||||
|
{item.description}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>{formatValue(item.type)}</span>
|
<span style={`${BADGE_STYLE};${typeBadgeStyle(item.type)}`}>
|
||||||
|
{formatValue(item.type)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>{formatValue(item.priority)}</span>
|
<span style={`${BADGE_STYLE};${priorityBadgeStyle(item.priority)}`}>
|
||||||
|
{formatValue(item.priority)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>{formatValue(item.status)}</span>
|
<span style={`${BADGE_STYLE};${statusBadgeStyle(item.status)}`}>
|
||||||
|
{formatValue(item.status)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="font-size:13px">{item.requesterName || '—'}</div>
|
<div style="font-size:13px">{item.requesterName || "—"}</div>
|
||||||
<div style="font-size:11px;color:#64748b">{item.requesterEmail || ''}</div>
|
<div style="font-size:11px;color:#64748b">
|
||||||
|
{item.requesterEmail || ""}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-slate-500" style="font-size:12px">
|
<td class="text-slate-500" style="font-size:12px">
|
||||||
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : '—'}
|
{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<A class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" href={`/admin/support/${item.id}`}>View</A>
|
<A
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
href={`/admin/support/${item.id}`}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</A>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -411,14 +563,45 @@ export default function SupportPage() {
|
||||||
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
<Show when={!cases.loading && !cases.error && filteredCases().length > 0}>
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
|
<div style="display:flex;align-items:center;justify-content:space-between;border-top:1px solid #F3F4F6;padding:12px 20px">
|
||||||
<p style="font-size:13px;color:#6B7280">
|
<p style="font-size:13px;color:#6B7280">
|
||||||
Showing <strong style="font-weight:600;color:#111827">1–{filteredCases().length}</strong> of <strong style="font-weight:600;color:#111827">{filteredCases().length}</strong> cases
|
Showing{" "}
|
||||||
|
<strong style="font-weight:600;color:#111827">
|
||||||
|
1–{filteredCases().length}
|
||||||
|
</strong>{" "}
|
||||||
|
of{" "}
|
||||||
|
<strong style="font-weight:600;color:#111827">{filteredCases().length}</strong>{" "}
|
||||||
|
cases
|
||||||
</p>
|
</p>
|
||||||
<div style="display:flex;align-items:center;gap:4px">
|
<div style="display:flex;align-items:center;gap:4px">
|
||||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px">‹</button>
|
<button
|
||||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer">1</button>
|
type="button"
|
||||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">2</button>
|
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer">3</button>
|
>
|
||||||
<button type="button" style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px">›</button>
|
‹
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;background:#FF5E13;color:white;font-size:13px;font-weight:600;border:none;cursor:pointer"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#374151;font-size:13px;font-weight:500;cursor:pointer"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style="display:inline-flex;width:30px;height:30px;align-items:center;justify-content:center;border-radius:7px;border:1px solid #E5E7EB;background:white;color:#6B7280;cursor:pointer;font-size:15px"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -427,11 +610,14 @@ export default function SupportPage() {
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Create Case Tab */}
|
{/* Create Case Tab */}
|
||||||
<Show when={activeTab() === 'create'}>
|
<Show when={activeTab() === "create"}>
|
||||||
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
<section class="rounded-xl border border-gray-200 bg-white shadow-sm p-6">
|
||||||
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">Create Support Case</h2>
|
<h2 style="margin:0 0 6px;font-size:16px;font-weight:700;color:#1e293b">
|
||||||
|
Create Support Case
|
||||||
|
</h2>
|
||||||
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
|
<p style="margin:0 0 20px;font-size:13px;color:#64748b">
|
||||||
Create an internal support record for platform issues, customer concerns, or compensation-related reviews.
|
Create an internal support record for platform issues, customer concerns, or
|
||||||
|
compensation-related reviews.
|
||||||
</p>
|
</p>
|
||||||
<Show when={createSuccess()}>
|
<Show when={createSuccess()}>
|
||||||
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
|
<div style="background:#dcfce7;border:1px solid #86efac;border-radius:6px;padding:10px 14px;margin-bottom:14px;font-size:14px;color:#15803d;font-weight:600">
|
||||||
|
|
@ -439,11 +625,18 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={createError()}>
|
<Show when={createError()}>
|
||||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" style="margin-bottom:14px">{createError()}</div>
|
<div
|
||||||
|
class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||||
|
style="margin-bottom:14px"
|
||||||
|
>
|
||||||
|
{createError()}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
|
<form onSubmit={handleCreate} style="display:flex;flex-direction:column;gap:16px">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Title</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Title
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
|
|
@ -453,7 +646,9 @@ export default function SupportPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Description</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows="4"
|
rows="4"
|
||||||
|
|
@ -464,10 +659,12 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Type</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={fType()}
|
value={fType()}
|
||||||
onChange={(e) => setFType(e.currentTarget.value as SupportCase['type'])}
|
onChange={(e) => setFType(e.currentTarget.value as SupportCase["type"])}
|
||||||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||||||
>
|
>
|
||||||
<For each={TYPE_OPTIONS}>
|
<For each={TYPE_OPTIONS}>
|
||||||
|
|
@ -476,10 +673,12 @@ export default function SupportPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Priority</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={fPriority()}
|
value={fPriority()}
|
||||||
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase['priority'])}
|
onChange={(e) => setFPriority(e.currentTarget.value as SupportCase["priority"])}
|
||||||
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
style="width:100%;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;font-size:14px;box-sizing:border-box"
|
||||||
>
|
>
|
||||||
<For each={PRIORITY_OPTIONS}>
|
<For each={PRIORITY_OPTIONS}>
|
||||||
|
|
@ -490,7 +689,9 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Name</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Requester Name
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fRequesterName()}
|
value={fRequesterName()}
|
||||||
|
|
@ -499,7 +700,9 @@ export default function SupportPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Requester Email</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Requester Email
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={fRequesterEmail()}
|
value={fRequesterEmail()}
|
||||||
|
|
@ -509,7 +712,9 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">Assign To (optional)</label>
|
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:4px">
|
||||||
|
Assign To (optional)
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={fAssignedTo()}
|
value={fAssignedTo()}
|
||||||
onChange={(e) => setFAssignedTo(e.currentTarget.value)}
|
onChange={(e) => setFAssignedTo(e.currentTarget.value)}
|
||||||
|
|
@ -519,7 +724,8 @@ export default function SupportPage() {
|
||||||
<For each={assignees()}>
|
<For each={assignees()}>
|
||||||
{(assignee) => (
|
{(assignee) => (
|
||||||
<option value={assignee.id}>
|
<option value={assignee.id}>
|
||||||
{assignee.name}{assignee.email ? ` (${assignee.email})` : ''}
|
{assignee.name}
|
||||||
|
{assignee.email ? ` (${assignee.email})` : ""}
|
||||||
</option>
|
</option>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -527,7 +733,7 @@ export default function SupportPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn-primary" type="submit" disabled={createLoading()}>
|
<button class="btn-primary" type="submit" disabled={createLoading()}>
|
||||||
{createLoading() ? 'Creating...' : 'Create Support Case'}
|
{createLoading() ? "Creating..." : "Create Support Case"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -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,60 +52,64 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -116,25 +120,42 @@ export default function EditUserPage() {
|
||||||
<div class="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
<div 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">{error()}</div>
|
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={user.loading}>
|
<Show when={user.loading}>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">Loading user...</p></div>
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<p class="notice">Loading user...</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!user.loading && !user()}>
|
<Show when={!user.loading && !user()}>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white shadow-sm"><p class="notice">User not found.</p></div>
|
<div class="rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<p class="notice">User not found.</p>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={user()}>
|
<Show when={user()}>
|
||||||
|
|
@ -142,15 +163,28 @@ export default function EditUserPage() {
|
||||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Full Name</label>
|
||||||
<input class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={name()} onInput={(e) => setName(e.currentTarget.value)} />
|
<input
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
|
||||||
|
value={name()}
|
||||||
|
onInput={(e) => setName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||||
<input type="email" class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={email()} onInput={(e) => setEmail(e.currentTarget.value)} />
|
<input
|
||||||
|
type="email"
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
|
||||||
|
value={email()}
|
||||||
|
onInput={(e) => setEmail(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Role</label>
|
||||||
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={roleId()} onChange={(e) => setRoleId(e.currentTarget.value)}>
|
<select
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
|
||||||
|
value={roleId()}
|
||||||
|
onChange={(e) => setRoleId(e.currentTarget.value)}
|
||||||
|
>
|
||||||
<option value="">Select role</option>
|
<option value="">Select role</option>
|
||||||
<Show when={!roles.loading}>
|
<Show when={!roles.loading}>
|
||||||
{roles()?.map((r) => (
|
{roles()?.map((r) => (
|
||||||
|
|
@ -161,7 +195,13 @@ export default function EditUserPage() {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
|
<label class="mb-1.5 block text-sm font-medium text-gray-700">Status</label>
|
||||||
<select class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]" value={status()} onChange={(e) => setStatus(e.currentTarget.value as 'ACTIVE' | 'INACTIVE' | 'PENDING')}>
|
<select
|
||||||
|
class="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-[#0a1d37] focus:ring-1 focus:ring-[#0a1d37]"
|
||||||
|
value={status()}
|
||||||
|
onChange={(e) =>
|
||||||
|
setStatus(e.currentTarget.value as "ACTIVE" | "INACTIVE" | "PENDING")
|
||||||
|
}
|
||||||
|
>
|
||||||
<option value="ACTIVE">Active</option>
|
<option value="ACTIVE">Active</option>
|
||||||
<option value="PENDING">Pending</option>
|
<option value="PENDING">Pending</option>
|
||||||
<option value="INACTIVE">Inactive</option>
|
<option value="INACTIVE">Inactive</option>
|
||||||
|
|
@ -170,9 +210,15 @@ export default function EditUserPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
|
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
|
||||||
<button class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors" type="button" onClick={() => navigate('/admin/users')}>Cancel</button>
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/admin/users")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
|
<button class="btn-primary" type="button" onClick={save} disabled={submitting()}>
|
||||||
{submitting() ? 'Saving…' : 'Save Changes'}
|
{submitting() ? "Saving…" : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue