@mohasinac/appkit 2.6.5 → 2.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_internal/client/features/layout/DashboardLayoutClient.d.ts +8 -1
- package/dist/_internal/client/features/layout/DashboardLayoutClient.js +18 -2
- package/dist/_internal/server/features/auth/capabilities.d.ts +17 -0
- package/dist/_internal/server/features/auth/capabilities.js +31 -0
- package/dist/_internal/server/features/auth/index.d.ts +2 -0
- package/dist/_internal/server/features/auth/index.js +2 -0
- package/dist/_internal/server/features/auth/permissions.d.ts +49 -0
- package/dist/_internal/server/features/auth/permissions.js +76 -0
- package/dist/configs/next.d.ts +6 -1
- package/dist/configs/next.js +45 -18
- package/dist/constants/api-endpoints.d.ts +69 -0
- package/dist/constants/api-endpoints.js +34 -0
- package/dist/contracts/registry.d.ts +7 -0
- package/dist/features/admin/components/AdminEmployeeEditorView.d.ts +10 -0
- package/dist/features/admin/components/AdminEmployeeEditorView.js +168 -0
- package/dist/features/admin/components/AdminSidebar.d.ts +2 -0
- package/dist/features/admin/components/AdminStoreEditorView.d.ts +2 -1
- package/dist/features/admin/components/AdminStoreEditorView.js +55 -3
- package/dist/features/admin/components/AdminStoresView.js +3 -1
- package/dist/features/admin/components/AdminSupportTicketDetailView.d.ts +23 -0
- package/dist/features/admin/components/AdminSupportTicketDetailView.js +83 -0
- package/dist/features/admin/components/AdminSupportTicketsView.d.ts +4 -0
- package/dist/features/admin/components/AdminSupportTicketsView.js +151 -0
- package/dist/features/admin/components/AdminTeamView.d.ts +4 -0
- package/dist/features/admin/components/AdminTeamView.js +139 -0
- package/dist/features/admin/components/AdminUserEditorView.d.ts +14 -1
- package/dist/features/admin/components/AdminUserEditorView.js +116 -14
- package/dist/features/admin/components/AdminUsersView.js +39 -12
- package/dist/features/admin/components/index.d.ts +8 -0
- package/dist/features/admin/components/index.js +4 -0
- package/dist/features/admin/constants/filter-tabs.d.ts +37 -0
- package/dist/features/admin/constants/filter-tabs.js +17 -0
- package/dist/features/auth/server/checkSoftBan.d.ts +15 -0
- package/dist/features/auth/server/checkSoftBan.js +36 -0
- package/dist/features/auth/server.d.ts +1 -0
- package/dist/features/auth/server.js +1 -0
- package/dist/features/media/types/index.js +2 -1
- package/dist/features/stores/repository/store.repository.d.ts +5 -1
- package/dist/features/stores/repository/store.repository.js +27 -2
- package/dist/features/support/repository/support.repository.d.ts +18 -0
- package/dist/features/support/repository/support.repository.js +117 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/next/api/routeHandler.d.ts +8 -0
- package/dist/next/api/routeHandler.js +24 -3
- package/dist/next/routing/route-map.d.ts +2 -0
- package/dist/next/routing/route-map.js +1 -0
- package/dist/repositories/index.d.ts +2 -0
- package/dist/repositories/index.js +1 -0
- package/dist/security/pii-schemas.d.ts +2 -0
- package/dist/security/pii-schemas.js +2 -0
- package/dist/seed/stores-seed-data.js +8 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +6 -0
- package/dist/tailwind-utilities.css +1 -1
- package/dist/utils/media-url.d.ts +9 -0
- package/dist/utils/media-url.js +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { Button, ConfirmDeleteModal, Form, FormActions, Select, SideDrawer, useToast, } from "../../../ui";
|
|
6
|
+
import { apiClient } from "../../../http";
|
|
7
|
+
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
|
+
import { PERMISSION_GROUPS, } from "../../auth/permissions/constants";
|
|
9
|
+
// --- Helpers -----------------------------------------------------------------
|
|
10
|
+
const GROUP_OPTIONS = [
|
|
11
|
+
{ label: "Content Moderator", value: "content_moderator" },
|
|
12
|
+
{ label: "Review Manager", value: "review_manager" },
|
|
13
|
+
{ label: "Blog Poster", value: "blog_poster" },
|
|
14
|
+
{ label: "Community Manager", value: "community_manager" },
|
|
15
|
+
{ label: "Event Handler", value: "event_handler" },
|
|
16
|
+
{ label: "Newsletter Manager", value: "newsletter_manager" },
|
|
17
|
+
{ label: "SEO Manager", value: "seo_manager" },
|
|
18
|
+
{ label: "Ad Manager", value: "ad_manager" },
|
|
19
|
+
{ label: "Site Manager", value: "site_manager" },
|
|
20
|
+
{ label: "Catalog Manager", value: "catalog_manager" },
|
|
21
|
+
{ label: "Finance Manager", value: "finance_manager" },
|
|
22
|
+
{ label: "Data Analyst", value: "data_analyst" },
|
|
23
|
+
{ label: "Customer Support", value: "customer_support" },
|
|
24
|
+
{ label: "Support Agent", value: "support_agent" },
|
|
25
|
+
{ label: "Store Onboarding", value: "store_onboarding" },
|
|
26
|
+
{ label: "Trust & Safety", value: "trust_and_safety" },
|
|
27
|
+
{ label: "Auction Monitor", value: "auction_monitor" },
|
|
28
|
+
{ label: "Scam Moderator", value: "scam_moderator" },
|
|
29
|
+
{ label: "Custom", value: "custom" },
|
|
30
|
+
];
|
|
31
|
+
const PERMISSION_DOMAINS = [
|
|
32
|
+
{ label: "Dashboard", prefix: "admin:dashboard:" },
|
|
33
|
+
{ label: "Users & Bans", prefix: "admin:users:|admin:user-bans:" },
|
|
34
|
+
{ label: "Products", prefix: "admin:products:" },
|
|
35
|
+
{ label: "Orders & Returns", prefix: "admin:orders:|admin:returns:" },
|
|
36
|
+
{ label: "Stores", prefix: "admin:stores:|admin:store-addresses:" },
|
|
37
|
+
{ label: "Finance", prefix: "admin:analytics:|admin:payouts:" },
|
|
38
|
+
{ label: "Catalog", prefix: "admin:categories:|admin:brands:|admin:coupons:|admin:deals:|admin:featured:" },
|
|
39
|
+
{
|
|
40
|
+
label: "Content",
|
|
41
|
+
prefix: "admin:reviews:|admin:blog:|admin:bids:|admin:media:",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
label: "Site / CMS",
|
|
45
|
+
prefix: "admin:site:|admin:navigation:|admin:sections:|admin:carousel:|admin:ads:|admin:faqs:|admin:newsletter:|admin:contact:",
|
|
46
|
+
},
|
|
47
|
+
{ label: "Events", prefix: "admin:events:|admin:event-entries:" },
|
|
48
|
+
{ label: "Support & Safety", prefix: "admin:support-tickets:|admin:scammers:" },
|
|
49
|
+
{ label: "System", prefix: "admin:sessions:|admin:notifications:|admin:carts:|admin:wishlists:|admin:feature-flags:|admin:copilot:|admin:team:" },
|
|
50
|
+
];
|
|
51
|
+
function matchesDomain(perm, prefix) {
|
|
52
|
+
return prefix.split("|").some((p) => perm.startsWith(p));
|
|
53
|
+
}
|
|
54
|
+
function getPermissionsForDomain(prefix) {
|
|
55
|
+
const allPerms = Object.values(PERMISSION_GROUPS).flat();
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const result = [];
|
|
58
|
+
for (const p of allPerms) {
|
|
59
|
+
if (matchesDomain(p, prefix) && !seen.has(p)) {
|
|
60
|
+
seen.add(p);
|
|
61
|
+
result.push(p);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
function formatPermLabel(perm) {
|
|
67
|
+
const parts = perm.split(":");
|
|
68
|
+
return parts[parts.length - 1]
|
|
69
|
+
.replace(/-/g, " ")
|
|
70
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
71
|
+
}
|
|
72
|
+
// --- Component ---------------------------------------------------------------
|
|
73
|
+
export function AdminEmployeeEditorView({ open, onClose, mode, userId, displayName, currentPermissionGroup, currentPermissions = [], }) {
|
|
74
|
+
const queryClient = useQueryClient();
|
|
75
|
+
const { showToast } = useToast();
|
|
76
|
+
const [email, setEmail] = React.useState("");
|
|
77
|
+
const [group, setGroup] = React.useState(currentPermissionGroup ?? "custom");
|
|
78
|
+
const [permissions, setPermissions] = React.useState(new Set(currentPermissions));
|
|
79
|
+
const [revokeOpen, setRevokeOpen] = React.useState(false);
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (open) {
|
|
82
|
+
setEmail("");
|
|
83
|
+
setGroup(currentPermissionGroup ?? "custom");
|
|
84
|
+
setPermissions(new Set(currentPermissions));
|
|
85
|
+
}
|
|
86
|
+
}, [open, currentPermissionGroup, currentPermissions]);
|
|
87
|
+
const applyGroupPreset = (newGroup) => {
|
|
88
|
+
setGroup(newGroup);
|
|
89
|
+
if (newGroup !== "custom") {
|
|
90
|
+
const preset = PERMISSION_GROUPS[newGroup] ?? [];
|
|
91
|
+
setPermissions(new Set(preset));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const togglePerm = (perm) => {
|
|
95
|
+
setPermissions((prev) => {
|
|
96
|
+
const next = new Set(prev);
|
|
97
|
+
if (next.has(perm))
|
|
98
|
+
next.delete(perm);
|
|
99
|
+
else
|
|
100
|
+
next.add(perm);
|
|
101
|
+
return next;
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const inviteMutation = useMutation({
|
|
105
|
+
mutationFn: async () => {
|
|
106
|
+
await apiClient.post(ADMIN_ENDPOINTS.TEAM, {
|
|
107
|
+
email: email.trim(),
|
|
108
|
+
permissionGroup: group,
|
|
109
|
+
permissions: Array.from(permissions),
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
onSuccess: () => {
|
|
113
|
+
showToast("Employee invited successfully", "success");
|
|
114
|
+
queryClient.invalidateQueries({ queryKey: ["admin", "team"] });
|
|
115
|
+
onClose();
|
|
116
|
+
},
|
|
117
|
+
onError: (err) => {
|
|
118
|
+
showToast(err.message ?? "Failed to invite employee", "error");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
const updateMutation = useMutation({
|
|
122
|
+
mutationFn: async () => {
|
|
123
|
+
await apiClient.put(ADMIN_ENDPOINTS.TEAM_MEMBER(userId), {
|
|
124
|
+
permissionGroup: group,
|
|
125
|
+
permissions: Array.from(permissions),
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
onSuccess: () => {
|
|
129
|
+
showToast("Permissions updated", "success");
|
|
130
|
+
queryClient.invalidateQueries({ queryKey: ["admin", "team"] });
|
|
131
|
+
onClose();
|
|
132
|
+
},
|
|
133
|
+
onError: (err) => {
|
|
134
|
+
showToast(err.message ?? "Failed to update permissions", "error");
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const revokeMutation = useMutation({
|
|
138
|
+
mutationFn: async () => {
|
|
139
|
+
await apiClient.delete(ADMIN_ENDPOINTS.TEAM_MEMBER(userId));
|
|
140
|
+
},
|
|
141
|
+
onSuccess: () => {
|
|
142
|
+
showToast("Employee access revoked", "success");
|
|
143
|
+
queryClient.invalidateQueries({ queryKey: ["admin", "team"] });
|
|
144
|
+
setRevokeOpen(false);
|
|
145
|
+
onClose();
|
|
146
|
+
},
|
|
147
|
+
onError: (err) => {
|
|
148
|
+
showToast(err.message ?? "Failed to revoke access", "error");
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
const isBusy = inviteMutation.isPending ||
|
|
152
|
+
updateMutation.isPending ||
|
|
153
|
+
revokeMutation.isPending;
|
|
154
|
+
const handleSubmit = (e) => {
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (mode === "invite")
|
|
157
|
+
inviteMutation.mutate();
|
|
158
|
+
else
|
|
159
|
+
updateMutation.mutate();
|
|
160
|
+
};
|
|
161
|
+
return (_jsxs(_Fragment, { children: [_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: mode === "invite" ? "Invite Employee" : `Edit — ${displayName ?? "Employee"}`, children: _jsxs(Form, { onSubmit: handleSubmit, className: "flex flex-col gap-5 p-4", children: [mode === "invite" && (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Email address" }), _jsx("input", { type: "email", required: true, value: email, onChange: (e) => setEmail(e.target.value), placeholder: "employee@example.com", className: "rounded-lg border border-zinc-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary/60 transition" })] })), _jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Permission group" }), _jsx(Select, { value: group, onChange: (e) => applyGroupPreset(e.target.value), options: GROUP_OPTIONS }), _jsx("p", { className: "text-xs text-zinc-500 dark:text-zinc-400", children: "Selecting a group auto-fills the permissions below. You can still customise individual permissions." })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("span", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: ["Permissions", _jsxs("span", { className: "ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400", children: ["(", permissions.size, " selected)"] })] }), _jsx("div", { className: "rounded-xl border border-zinc-200 dark:border-slate-700 divide-y divide-zinc-100 dark:divide-slate-700 max-h-[42vh] overflow-y-auto", children: PERMISSION_DOMAINS.map((domain) => {
|
|
162
|
+
const domainPerms = getPermissionsForDomain(domain.prefix);
|
|
163
|
+
if (domainPerms.length === 0)
|
|
164
|
+
return null;
|
|
165
|
+
const checked = domainPerms.filter((p) => permissions.has(p)).length;
|
|
166
|
+
return (_jsxs("details", { className: "group", children: [_jsxs("summary", { className: "flex cursor-pointer items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wide text-zinc-600 dark:text-zinc-400 select-none hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: [_jsx("span", { children: domain.label }), _jsxs("span", { className: "text-xs font-normal normal-case text-zinc-400 dark:text-zinc-500", children: [checked, "/", domainPerms.length] })] }), _jsx("div", { className: "grid grid-cols-2 gap-x-2 gap-y-1.5 px-3 py-2.5 bg-zinc-50/60 dark:bg-slate-800/40", children: domainPerms.map((perm) => (_jsxs("label", { className: "flex items-center gap-2 cursor-pointer text-xs text-zinc-700 dark:text-zinc-300", children: [_jsx("input", { type: "checkbox", checked: permissions.has(perm), onChange: () => togglePerm(perm), className: "h-3.5 w-3.5 rounded border-zinc-300 dark:border-slate-600 accent-primary" }), formatPermLabel(perm)] }, perm))) })] }, domain.prefix));
|
|
167
|
+
}) })] }), _jsxs(FormActions, { children: [_jsx(Button, { type: "submit", variant: "primary", disabled: isBusy, isLoading: isBusy, children: mode === "invite" ? "Send Invite" : "Save" }), mode === "edit" && userId && (_jsx(Button, { type: "button", variant: "danger", disabled: isBusy, onClick: () => setRevokeOpen(true), children: "Revoke Access" }))] })] }) }), mode === "edit" && (_jsx(ConfirmDeleteModal, { isOpen: revokeOpen, onClose: () => setRevokeOpen(false), onConfirm: () => revokeMutation.mutate(), isDeleting: revokeMutation.isPending, title: "Revoke employee access?", message: `${displayName ?? "This employee"} will lose admin panel access immediately. Their user account remains active — only their role is reset to user.`, confirmText: "Revoke Access", variant: "danger" }))] }));
|
|
168
|
+
}
|
|
@@ -5,5 +5,6 @@ export interface AdminStoreEditorViewProps {
|
|
|
5
5
|
storeName?: string;
|
|
6
6
|
currentStatus?: string;
|
|
7
7
|
currentIsVerified?: boolean;
|
|
8
|
+
currentCapabilities?: string[];
|
|
8
9
|
}
|
|
9
|
-
export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, }: AdminStoreEditorViewProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export declare function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentCapabilities, }: AdminStoreEditorViewProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -5,6 +5,42 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
5
5
|
import { Button, Form, FormActions, Select, SideDrawer, Toggle, useToast, } from "../../../ui";
|
|
6
6
|
import { apiClient } from "../../../http";
|
|
7
7
|
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
|
+
// --- Capability groups -------------------------------------------------------
|
|
9
|
+
const CAPABILITY_GROUPS = [
|
|
10
|
+
{
|
|
11
|
+
label: "Listings",
|
|
12
|
+
caps: [
|
|
13
|
+
{ key: "host_auctions", label: "Host Auctions" },
|
|
14
|
+
{ key: "host_preorders", label: "Host Pre-orders" },
|
|
15
|
+
{ key: "create_categories", label: "Request New Categories" },
|
|
16
|
+
{ key: "suggest_brands", label: "Suggest Brands" },
|
|
17
|
+
{ key: "create_coupons", label: "Create Coupons" },
|
|
18
|
+
{ key: "bulk_listing_import", label: "Bulk Listing Import" },
|
|
19
|
+
{ key: "extended_return_window", label: "Extended Return Window" },
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
label: "Trust & Visibility",
|
|
24
|
+
caps: [
|
|
25
|
+
{ key: "verified_seller", label: "Verified Seller Badge" },
|
|
26
|
+
{ key: "featured_placement", label: "Featured Placement" },
|
|
27
|
+
{ key: "promotional_banner", label: "Promotional Banner" },
|
|
28
|
+
{ key: "priority_support", label: "Priority Support" },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
label: "Platform",
|
|
33
|
+
caps: [
|
|
34
|
+
{ key: "multiple_stores", label: "Multiple Stores" },
|
|
35
|
+
{ key: "custom_store_slug", label: "Custom Store Slug" },
|
|
36
|
+
{ key: "api_access", label: "API Access" },
|
|
37
|
+
{ key: "lower_commission_rate", label: "Lower Commission Rate" },
|
|
38
|
+
{ key: "early_access_features", label: "Early Access Features" },
|
|
39
|
+
{ key: "advanced_analytics", label: "Advanced Analytics" },
|
|
40
|
+
{ key: "whatsapp_catalog_sync", label: "WhatsApp Catalog Sync" },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
8
44
|
const STATUS_OPTIONS = [
|
|
9
45
|
{ label: "Active", value: "active" },
|
|
10
46
|
{ label: "Pending", value: "pending" },
|
|
@@ -12,7 +48,7 @@ const STATUS_OPTIONS = [
|
|
|
12
48
|
{ label: "Rejected", value: "rejected" },
|
|
13
49
|
];
|
|
14
50
|
// --- Component ---------------------------------------------------------------
|
|
15
|
-
export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, }) {
|
|
51
|
+
export function AdminStoreEditorView({ open, onClose, storeId, storeName, currentStatus, currentIsVerified, currentCapabilities, }) {
|
|
16
52
|
const queryClient = useQueryClient();
|
|
17
53
|
const { showToast } = useToast();
|
|
18
54
|
const [storeStatus, setStoreStatus] = React.useState(currentStatus ?? "pending");
|
|
@@ -20,6 +56,17 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
20
56
|
const [isFeatured, setIsFeatured] = React.useState(false);
|
|
21
57
|
const [isVerified, setIsVerified] = React.useState(currentIsVerified ?? false);
|
|
22
58
|
const [suspensionReason, setSuspensionReason] = React.useState("");
|
|
59
|
+
const [capabilities, setCapabilities] = React.useState(new Set(currentCapabilities ?? ["suggest_brands", "create_coupons"]));
|
|
60
|
+
const toggleCapability = (key) => {
|
|
61
|
+
setCapabilities((prev) => {
|
|
62
|
+
const next = new Set(prev);
|
|
63
|
+
if (next.has(key))
|
|
64
|
+
next.delete(key);
|
|
65
|
+
else
|
|
66
|
+
next.add(key);
|
|
67
|
+
return next;
|
|
68
|
+
});
|
|
69
|
+
};
|
|
23
70
|
React.useEffect(() => {
|
|
24
71
|
if (open) {
|
|
25
72
|
setStoreStatus(currentStatus ?? "pending");
|
|
@@ -27,8 +74,9 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
27
74
|
setIsFeatured(false);
|
|
28
75
|
setIsVerified(currentIsVerified ?? false);
|
|
29
76
|
setSuspensionReason("");
|
|
77
|
+
setCapabilities(new Set(currentCapabilities ?? ["suggest_brands", "create_coupons"]));
|
|
30
78
|
}
|
|
31
|
-
}, [open, currentStatus, currentIsVerified]);
|
|
79
|
+
}, [open, currentStatus, currentIsVerified, currentCapabilities]);
|
|
32
80
|
const saveMutation = useMutation({
|
|
33
81
|
mutationFn: async () => {
|
|
34
82
|
await apiClient.patch(ADMIN_ENDPOINTS.STORE_BY_ID(storeId), {
|
|
@@ -37,6 +85,7 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
37
85
|
isFeatured,
|
|
38
86
|
isVerified,
|
|
39
87
|
suspensionReason: storeStatus === "suspended" ? (suspensionReason || undefined) : undefined,
|
|
88
|
+
capabilities: Array.from(capabilities),
|
|
40
89
|
});
|
|
41
90
|
},
|
|
42
91
|
onSuccess: () => {
|
|
@@ -51,5 +100,8 @@ export function AdminStoreEditorView({ open, onClose, storeId, storeName, curren
|
|
|
51
100
|
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: storeName ? `Manage: ${storeName}` : "Manage Store", children: _jsxs(Form, { onSubmit: (e) => {
|
|
52
101
|
e.preventDefault();
|
|
53
102
|
saveMutation.mutate();
|
|
54
|
-
}, className: "space-y-4 p-4", children: [_jsx(Select, { label: "Store status", options: STATUS_OPTIONS, value: storeStatus, onValueChange: setStoreStatus }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Admin notes (optional)" }), _jsx("textarea", { value: adminNotes, onChange: (e) => setAdminNotes(e.target.value), rows: 3, placeholder: "e.g. Reason for suspension, approval notes\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] }), _jsx(Toggle, { label: "Verified store", checked: isVerified, onChange: setIsVerified }), _jsx(Toggle, { label: "Featured store", checked: isFeatured, onChange: setIsFeatured }), storeStatus === "suspended" && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Suspension reason (optional)" }), _jsx("textarea", { value: suspensionReason, onChange: (e) => setSuspensionReason(e.target.value), rows: 2, placeholder: "e.g. Policy violation, fraudulent activity\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] })), _jsxs(
|
|
103
|
+
}, className: "space-y-4 p-4", children: [_jsx(Select, { label: "Store status", options: STATUS_OPTIONS, value: storeStatus, onValueChange: setStoreStatus }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Admin notes (optional)" }), _jsx("textarea", { value: adminNotes, onChange: (e) => setAdminNotes(e.target.value), rows: 3, placeholder: "e.g. Reason for suspension, approval notes\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] }), _jsx(Toggle, { label: "Verified store", checked: isVerified, onChange: setIsVerified }), _jsx(Toggle, { label: "Featured store", checked: isFeatured, onChange: setIsFeatured }), storeStatus === "suspended" && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: "Suspension reason (optional)" }), _jsx("textarea", { value: suspensionReason, onChange: (e) => setSuspensionReason(e.target.value), rows: 2, placeholder: "e.g. Policy violation, fraudulent activity\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("span", { className: "text-sm font-medium text-zinc-700 dark:text-zinc-300", children: ["Capabilities", _jsxs("span", { className: "ml-2 text-xs font-normal text-zinc-500 dark:text-zinc-400", children: ["(", capabilities.size, " active)"] })] }), _jsx("div", { className: "rounded-xl border border-zinc-200 dark:border-slate-700 divide-y divide-zinc-100 dark:divide-slate-700", children: CAPABILITY_GROUPS.map((group) => {
|
|
104
|
+
const checked = group.caps.filter((c) => capabilities.has(c.key)).length;
|
|
105
|
+
return (_jsxs("details", { className: "group", children: [_jsxs("summary", { className: "flex cursor-pointer items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wide text-zinc-600 dark:text-zinc-400 select-none hover:bg-zinc-50 dark:hover:bg-slate-800 transition-colors", children: [_jsx("span", { children: group.label }), _jsxs("span", { className: "text-xs font-normal normal-case text-zinc-400 dark:text-zinc-500", children: [checked, "/", group.caps.length] })] }), _jsx("div", { className: "grid grid-cols-2 gap-x-2 gap-y-1.5 px-3 py-2.5 bg-zinc-50/60 dark:bg-slate-800/40", children: group.caps.map((cap) => (_jsxs("label", { className: "flex items-center gap-2 cursor-pointer text-xs text-zinc-700 dark:text-zinc-300", children: [_jsx("input", { type: "checkbox", checked: capabilities.has(cap.key), onChange: () => toggleCapability(cap.key), className: "h-3.5 w-3.5 rounded border-zinc-300 dark:border-slate-600 accent-primary" }), cap.label] }, cap.key))) })] }, group.label));
|
|
106
|
+
}) })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", isLoading: saveMutation.isPending, disabled: !storeId || saveMutation.isPending, children: "Save changes" })] })] }) }));
|
|
55
107
|
}
|
|
@@ -84,5 +84,7 @@ export function AdminStoresView({ children, ...props }) {
|
|
|
84
84
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search stores, slugs, or owner names", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: SORT_OPTIONS, onSortChange: (v) => { table.set("sort", v); table.setPage(1); }, hideViewToggle: true, onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx("div", { className: "sticky top-[calc(var(--header-height,0px)+44px)] z-10 flex justify-center bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-zinc-200 dark:border-slate-700 px-3 py-1.5", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })), _jsxs("div", { className: "py-4 px-3 sm:px-4", children: [errorMessage && (_jsx("div", { className: "mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200", children: errorMessage })), _jsx(DataTable, { rows: rows, isLoading: isLoading, emptyLabel: "No stores found", renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [{
|
|
85
85
|
label: "Manage",
|
|
86
86
|
onClick: () => openEditPanel(row.id),
|
|
87
|
-
}] })) })] }), filterOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40 bg-black/40", "aria-hidden": "true", onClick: () => setFilterOpen(false) }), _jsxs("div", { className: "fixed inset-y-0 left-0 z-50 flex w-80 flex-col bg-white dark:bg-slate-900 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: [_jsx("span", { className: "text-base font-semibold text-zinc-900 dark:text-zinc-100", children: "Filters" }), _jsxs("div", { className: "flex items-center gap-2", children: [activeFilterCount > 0 && (_jsx("button", { type: "button", onClick: clearFilters, className: "text-xs text-zinc-500 hover:text-rose-500 dark:text-zinc-400 transition-colors", children: "Clear all" })), _jsx("button", { type: "button", onClick: () => setFilterOpen(false), "aria-label": "Close", className: "rounded-lg p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-slate-800 transition-colors", children: _jsx(X, { className: "h-5 w-5" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-5", children: _jsx(FilterChipGroup, { label: "Status", tabs: STATUS_OPTIONS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) }) }), _jsx("div", { className: "border-t border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: _jsxs("button", { type: "button", onClick: applyFilters, className: "w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-white hover:bg-primary-600 transition-colors active:scale-[0.98]", children: ["Apply Filters", activeFilterCount > 0 ? ` (${activeFilterCount})` : ""] }) })] })] }))] }), _jsx(AdminStoreEditorView, { open: isEditOpen, onClose: closePanel, storeId: editId ?? undefined, storeName: panelRow?.primary, currentStatus: panelRow?.status?.toLowerCase(), currentIsVerified: Boolean(panelRow?._raw?.isVerified)
|
|
87
|
+
}] })) })] }), filterOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40 bg-black/40", "aria-hidden": "true", onClick: () => setFilterOpen(false) }), _jsxs("div", { className: "fixed inset-y-0 left-0 z-50 flex w-80 flex-col bg-white dark:bg-slate-900 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: [_jsx("span", { className: "text-base font-semibold text-zinc-900 dark:text-zinc-100", children: "Filters" }), _jsxs("div", { className: "flex items-center gap-2", children: [activeFilterCount > 0 && (_jsx("button", { type: "button", onClick: clearFilters, className: "text-xs text-zinc-500 hover:text-rose-500 dark:text-zinc-400 transition-colors", children: "Clear all" })), _jsx("button", { type: "button", onClick: () => setFilterOpen(false), "aria-label": "Close", className: "rounded-lg p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-slate-800 transition-colors", children: _jsx(X, { className: "h-5 w-5" }) })] })] }), _jsx("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-5", children: _jsx(FilterChipGroup, { label: "Status", tabs: STATUS_OPTIONS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) }) }), _jsx("div", { className: "border-t border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: _jsxs("button", { type: "button", onClick: applyFilters, className: "w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-white hover:bg-primary-600 transition-colors active:scale-[0.98]", children: ["Apply Filters", activeFilterCount > 0 ? ` (${activeFilterCount})` : ""] }) })] })] }))] }), _jsx(AdminStoreEditorView, { open: isEditOpen, onClose: closePanel, storeId: editId ?? undefined, storeName: panelRow?.primary, currentStatus: panelRow?.status?.toLowerCase(), currentIsVerified: Boolean(panelRow?._raw?.isVerified), currentCapabilities: Array.isArray(panelRow?._raw?.capabilities)
|
|
88
|
+
? panelRow._raw.capabilities
|
|
89
|
+
: undefined })] }));
|
|
88
90
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
interface TicketMessageClient {
|
|
2
|
+
id?: string;
|
|
3
|
+
authorId?: string;
|
|
4
|
+
authorRole?: string;
|
|
5
|
+
body?: string;
|
|
6
|
+
createdAt?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AdminSupportTicketDetailViewProps {
|
|
9
|
+
open: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
ticketId?: string;
|
|
12
|
+
subject?: string;
|
|
13
|
+
userDisplayName?: string;
|
|
14
|
+
category?: string;
|
|
15
|
+
currentStatus?: string;
|
|
16
|
+
currentPriority?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
messages?: TicketMessageClient[];
|
|
19
|
+
internalNotes?: string;
|
|
20
|
+
orderId?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages, internalNotes, orderId, }: AdminSupportTicketDetailViewProps): import("react/jsx-runtime").JSX.Element;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { Button, Div, FormActions, Select, SideDrawer, Text, useToast, } from "../../../ui";
|
|
6
|
+
import { apiClient } from "../../../http";
|
|
7
|
+
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
|
+
const STATUS_OPTIONS = [
|
|
9
|
+
{ label: "Open", value: "open" },
|
|
10
|
+
{ label: "In Progress", value: "in_progress" },
|
|
11
|
+
{ label: "Waiting on User", value: "waiting_on_user" },
|
|
12
|
+
{ label: "Resolved", value: "resolved" },
|
|
13
|
+
{ label: "Closed", value: "closed" },
|
|
14
|
+
];
|
|
15
|
+
const PRIORITY_OPTIONS = [
|
|
16
|
+
{ label: "Low", value: "low" },
|
|
17
|
+
{ label: "Normal", value: "normal" },
|
|
18
|
+
{ label: "High", value: "high" },
|
|
19
|
+
{ label: "Urgent", value: "urgent" },
|
|
20
|
+
];
|
|
21
|
+
const STATUS_COLOR = {
|
|
22
|
+
open: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
|
23
|
+
in_progress: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
|
|
24
|
+
waiting_on_user: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
|
|
25
|
+
resolved: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
|
|
26
|
+
closed: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
|
27
|
+
};
|
|
28
|
+
const ROLE_LABEL = {
|
|
29
|
+
user: "User",
|
|
30
|
+
support: "Support",
|
|
31
|
+
admin: "Admin",
|
|
32
|
+
};
|
|
33
|
+
export function AdminSupportTicketDetailView({ open, onClose, ticketId, subject, userDisplayName, category, currentStatus, currentPriority, description, messages = [], internalNotes, orderId, }) {
|
|
34
|
+
const queryClient = useQueryClient();
|
|
35
|
+
const { showToast } = useToast();
|
|
36
|
+
const [status, setStatus] = React.useState(currentStatus ?? "open");
|
|
37
|
+
const [priority, setPriority] = React.useState(currentPriority ?? "normal");
|
|
38
|
+
const [notes, setNotes] = React.useState(internalNotes ?? "");
|
|
39
|
+
const [replyBody, setReplyBody] = React.useState("");
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
if (open) {
|
|
42
|
+
setStatus(currentStatus ?? "open");
|
|
43
|
+
setPriority(currentPriority ?? "normal");
|
|
44
|
+
setNotes(internalNotes ?? "");
|
|
45
|
+
setReplyBody("");
|
|
46
|
+
}
|
|
47
|
+
}, [open, currentStatus, currentPriority, internalNotes]);
|
|
48
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: ["admin", "support-tickets"] });
|
|
49
|
+
const updateMutation = useMutation({
|
|
50
|
+
mutationFn: async () => {
|
|
51
|
+
await apiClient.patch(ADMIN_ENDPOINTS.SUPPORT_TICKET_BY_ID(ticketId), {
|
|
52
|
+
status,
|
|
53
|
+
priority,
|
|
54
|
+
internalNotes: notes || undefined,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
onSuccess: () => {
|
|
58
|
+
showToast("Ticket updated.", "success");
|
|
59
|
+
invalidate();
|
|
60
|
+
onClose();
|
|
61
|
+
},
|
|
62
|
+
onError: (err) => {
|
|
63
|
+
showToast(err?.message ?? "Failed to update ticket.", "error");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const replyMutation = useMutation({
|
|
67
|
+
mutationFn: async () => {
|
|
68
|
+
await apiClient.post(`/api/support/tickets/${ticketId}/messages`, { body: replyBody, newStatus: status });
|
|
69
|
+
},
|
|
70
|
+
onSuccess: () => {
|
|
71
|
+
showToast("Reply sent.", "success");
|
|
72
|
+
setReplyBody("");
|
|
73
|
+
invalidate();
|
|
74
|
+
onClose();
|
|
75
|
+
},
|
|
76
|
+
onError: (err) => {
|
|
77
|
+
showToast(err?.message ?? "Failed to send reply.", "error");
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return (_jsx(SideDrawer, { isOpen: open, onClose: onClose, title: subject ?? "Support Ticket", children: _jsxs(Div, { className: "flex flex-col gap-4 p-4", children: [_jsxs(Div, { className: "flex flex-wrap items-center gap-2", children: [_jsx("span", { className: `inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_COLOR[currentStatus ?? "open"] ?? STATUS_COLOR.open}`, children: (currentStatus ?? "open").replace(/_/g, " ") }), _jsx("span", { className: "rounded-full bg-zinc-100 px-2.5 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300", children: category }), orderId && (_jsxs("span", { className: "rounded-full bg-zinc-100 px-2.5 py-1 text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300", children: ["Order: ", orderId] }))] }), _jsxs(Div, { className: "text-sm text-zinc-500 dark:text-zinc-400", children: ["From: ", _jsx("span", { className: "font-medium text-zinc-700 dark:text-zinc-200", children: userDisplayName })] }), description && (_jsxs(Div, { className: "rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-900/40", children: [_jsx(Text, { className: "mb-1 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide", children: "Description" }), _jsx(Text, { className: "whitespace-pre-wrap text-sm text-zinc-700 dark:text-zinc-200", children: description })] })), messages.length > 0 && (_jsxs(Div, { className: "space-y-2", children: [_jsxs(Text, { className: "text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide", children: ["Messages (", messages.length, ")"] }), _jsx("div", { className: "space-y-2 max-h-64 overflow-y-auto", children: messages.map((msg, i) => (_jsxs("div", { className: `rounded-lg p-3 text-sm ${msg.authorRole === "user"
|
|
81
|
+
? "bg-zinc-50 border border-zinc-200 dark:bg-zinc-900/40 dark:border-zinc-700"
|
|
82
|
+
: "bg-blue-50 border border-blue-200 dark:bg-blue-900/20 dark:border-blue-800"}`, children: [_jsxs("div", { className: "mb-1 flex items-center gap-2 text-xs text-zinc-400 dark:text-zinc-500", children: [_jsx("span", { className: "font-medium text-zinc-600 dark:text-zinc-300", children: ROLE_LABEL[msg.authorRole ?? "user"] ?? msg.authorRole }), msg.createdAt && (_jsx("span", { children: new Date(msg.createdAt).toLocaleString() }))] }), _jsx("p", { className: "whitespace-pre-wrap text-zinc-700 dark:text-zinc-200", children: msg.body })] }, msg.id ?? i))) })] })), _jsxs(Div, { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide", children: "Reply to user" }), _jsx("textarea", { value: replyBody, onChange: (e) => setReplyBody(e.target.value), rows: 3, placeholder: "Type a reply\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" }), _jsx(Button, { type: "button", variant: "primary", size: "sm", isLoading: replyMutation.isPending, disabled: !replyBody.trim() || !ticketId || replyMutation.isPending, onClick: () => replyMutation.mutate(), children: "Send reply" })] }), _jsx("hr", { className: "border-zinc-200 dark:border-zinc-700" }), _jsx(Select, { label: "Status", options: STATUS_OPTIONS, value: status, onValueChange: setStatus }), _jsx(Select, { label: "Priority", options: PRIORITY_OPTIONS, value: priority, onValueChange: setPriority }), _jsxs(Div, { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wide", children: "Internal notes (staff only)" }), _jsx("textarea", { value: notes, onChange: (e) => setNotes(e.target.value), rows: 3, placeholder: "Notes visible only to admins and employees\u2026", className: "w-full rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-primary-500" })] }), _jsxs(FormActions, { align: "right", children: [_jsx(Button, { type: "button", variant: "secondary", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", isLoading: updateMutation.isPending, disabled: !ticketId || updateMutation.isPending, onClick: () => updateMutation.mutate(), children: "Save changes" })] })] }) }));
|
|
83
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ListingViewShellProps } from "../../../ui";
|
|
2
|
+
export interface AdminSupportTicketsViewProps extends ListingViewShellProps {
|
|
3
|
+
}
|
|
4
|
+
export declare function AdminSupportTicketsView({ children, ...props }: AdminSupportTicketsViewProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import React, { useState, useCallback } from "react";
|
|
4
|
+
import { X } from "lucide-react";
|
|
5
|
+
import { useUrlTable } from "../../../react/hooks/useUrlTable";
|
|
6
|
+
import { FilterChipGroup, ListingToolbar, ListingViewShell, Pagination, RowActionMenu, } from "../../../ui";
|
|
7
|
+
import { ADMIN_ENDPOINTS } from "../../../constants/api-endpoints";
|
|
8
|
+
import { ADMIN_SUPPORT_TICKET_STATUS_TABS, ADMIN_SUPPORT_TICKET_PRIORITY_TABS, } from "../constants/filter-tabs";
|
|
9
|
+
import { toRecordArray, toRelativeDate, toStringValue, useAdminListingData, } from "../hooks/useAdminListingData";
|
|
10
|
+
import { DataTable } from "./DataTable";
|
|
11
|
+
import { AdminSupportTicketDetailView } from "./AdminSupportTicketDetailView";
|
|
12
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
const FILTER_KEYS = ["status", "priority"];
|
|
14
|
+
const DEFAULT_SORT = "-createdAt";
|
|
15
|
+
const SORT_OPTIONS = [
|
|
16
|
+
{ value: "-createdAt", label: "Newest" },
|
|
17
|
+
{ value: "createdAt", label: "Oldest" },
|
|
18
|
+
{ value: "-updatedAt", label: "Recently updated" },
|
|
19
|
+
];
|
|
20
|
+
const PRIORITY_BADGE = {
|
|
21
|
+
urgent: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300",
|
|
22
|
+
high: "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300",
|
|
23
|
+
normal: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
|
24
|
+
low: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
|
25
|
+
};
|
|
26
|
+
const STATUS_BADGE = {
|
|
27
|
+
open: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300",
|
|
28
|
+
in_progress: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300",
|
|
29
|
+
waiting_on_user: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300",
|
|
30
|
+
resolved: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300",
|
|
31
|
+
closed: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
|
|
32
|
+
};
|
|
33
|
+
export function AdminSupportTicketsView({ children, ...props }) {
|
|
34
|
+
const hasChildren = React.Children.count(children) > 0;
|
|
35
|
+
const table = useUrlTable({ defaults: { pageSize: String(PAGE_SIZE), sort: DEFAULT_SORT } });
|
|
36
|
+
const [searchInput, setSearchInput] = useState(table.get("q") || "");
|
|
37
|
+
const [filterOpen, setFilterOpen] = useState(false);
|
|
38
|
+
const [pendingFilters, setPendingFilters] = useState(() => Object.fromEntries(FILTER_KEYS.map((k) => [k, table.get(k)])));
|
|
39
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
40
|
+
const [selectedRow, setSelectedRow] = useState(null);
|
|
41
|
+
const openFilters = useCallback(() => {
|
|
42
|
+
setPendingFilters(Object.fromEntries(FILTER_KEYS.map((k) => [k, table.get(k)])));
|
|
43
|
+
setFilterOpen(true);
|
|
44
|
+
}, [table]);
|
|
45
|
+
const applyFilters = useCallback(() => {
|
|
46
|
+
const updates = { page: "1" };
|
|
47
|
+
for (const k of FILTER_KEYS)
|
|
48
|
+
updates[k] = pendingFilters[k] ?? "";
|
|
49
|
+
table.setMany(updates);
|
|
50
|
+
setFilterOpen(false);
|
|
51
|
+
}, [pendingFilters, table]);
|
|
52
|
+
const clearFilters = useCallback(() => {
|
|
53
|
+
setPendingFilters(Object.fromEntries(FILTER_KEYS.map((k) => [k, ""])));
|
|
54
|
+
}, []);
|
|
55
|
+
const resetAll = useCallback(() => {
|
|
56
|
+
const updates = { q: "", sort: "" };
|
|
57
|
+
for (const k of FILTER_KEYS)
|
|
58
|
+
updates[k] = "";
|
|
59
|
+
table.setMany(updates);
|
|
60
|
+
setSearchInput("");
|
|
61
|
+
}, [table]);
|
|
62
|
+
const commitSearch = useCallback(() => {
|
|
63
|
+
table.set("q", searchInput.trim());
|
|
64
|
+
}, [searchInput, table]);
|
|
65
|
+
const activeFilterCount = FILTER_KEYS.filter((k) => !!table.get(k)).length;
|
|
66
|
+
const hasActiveState = !!table.get("q") || table.get("sort") !== DEFAULT_SORT || activeFilterCount > 0;
|
|
67
|
+
const filterParts = [];
|
|
68
|
+
const statusRaw = table.get("status");
|
|
69
|
+
if (statusRaw && statusRaw !== "All")
|
|
70
|
+
filterParts.push(`status==${statusRaw}`);
|
|
71
|
+
const priorityRaw = table.get("priority");
|
|
72
|
+
if (priorityRaw && priorityRaw !== "All")
|
|
73
|
+
filterParts.push(`priority==${priorityRaw}`);
|
|
74
|
+
const filters = filterParts.join(",") || undefined;
|
|
75
|
+
const { rows, total, isLoading, errorMessage } = useAdminListingData({
|
|
76
|
+
queryKey: ["admin", "support-tickets", "listing"],
|
|
77
|
+
endpoint: ADMIN_ENDPOINTS.SUPPORT_TICKETS,
|
|
78
|
+
page: table.getNumber("page", 1),
|
|
79
|
+
pageSize: PAGE_SIZE,
|
|
80
|
+
sorts: table.get("sort") || DEFAULT_SORT,
|
|
81
|
+
filters,
|
|
82
|
+
q: table.get("q") || undefined,
|
|
83
|
+
mapRows: (response) => toRecordArray(response.tickets).map((item, index) => ({
|
|
84
|
+
id: toStringValue(item.id, `ticket-${index}`),
|
|
85
|
+
primary: toStringValue(item.subject, "No subject"),
|
|
86
|
+
secondary: [
|
|
87
|
+
toStringValue(item.userDisplayName, "Unknown user"),
|
|
88
|
+
toStringValue(item.category, "general"),
|
|
89
|
+
item.orderId ? `Order: ${toStringValue(item.orderId, "")}` : null,
|
|
90
|
+
]
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
.join(" · "),
|
|
93
|
+
status: toStringValue(item.status, "open"),
|
|
94
|
+
updatedAt: toRelativeDate(item.updatedAt ?? item.createdAt),
|
|
95
|
+
_raw: item,
|
|
96
|
+
})),
|
|
97
|
+
getTotal: (response, mappedRows) => {
|
|
98
|
+
if (typeof response.meta?.filteredTotal === "number")
|
|
99
|
+
return response.meta.filteredTotal;
|
|
100
|
+
if (typeof response.meta?.total === "number")
|
|
101
|
+
return response.meta.total;
|
|
102
|
+
if (typeof response.total === "number")
|
|
103
|
+
return response.total;
|
|
104
|
+
return mappedRows.length;
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const currentPage = table.getNumber("page", 1);
|
|
108
|
+
const totalPages = Math.ceil(total / PAGE_SIZE);
|
|
109
|
+
if (hasChildren) {
|
|
110
|
+
return (_jsx(ListingViewShell, { portal: "admin", ...props, children: children }));
|
|
111
|
+
}
|
|
112
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "min-h-screen", children: [_jsx(ListingToolbar, { filterCount: activeFilterCount, onFiltersClick: openFilters, searchValue: searchInput, searchPlaceholder: "Search by subject", onSearchChange: setSearchInput, onSearchCommit: commitSearch, sortValue: table.get("sort") || DEFAULT_SORT, sortOptions: SORT_OPTIONS, onSortChange: (v) => {
|
|
113
|
+
table.set("sort", v);
|
|
114
|
+
table.setPage(1);
|
|
115
|
+
}, hideViewToggle: true, onResetAll: resetAll, hasActiveState: hasActiveState }), totalPages > 1 && (_jsx("div", { className: "sticky top-[calc(var(--header-height,0px)+44px)] z-10 flex justify-center bg-white/95 dark:bg-slate-900/95 backdrop-blur-sm border-b border-zinc-200 dark:border-slate-700 px-3 py-1.5", children: _jsx(Pagination, { currentPage: currentPage, totalPages: totalPages, onPageChange: (p) => table.setPage(p) }) })), _jsxs("div", { className: "py-4 px-3 sm:px-4", children: [errorMessage && (_jsx("div", { className: "mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-200", children: errorMessage })), _jsx(DataTable, { rows: rows, isLoading: isLoading, emptyLabel: "No support tickets found", renderRowActions: (row) => (_jsx(RowActionMenu, { actions: [
|
|
116
|
+
{
|
|
117
|
+
label: "View",
|
|
118
|
+
onClick: () => {
|
|
119
|
+
setSelectedRow(row);
|
|
120
|
+
setDrawerOpen(true);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
] })), columns: [
|
|
124
|
+
{
|
|
125
|
+
key: "primary",
|
|
126
|
+
header: "Subject",
|
|
127
|
+
render: (row) => {
|
|
128
|
+
const r = row;
|
|
129
|
+
const priority = toStringValue(r._raw?.priority, "normal");
|
|
130
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsx("p", { className: "font-medium text-zinc-900 dark:text-zinc-100", children: r.primary }), r.secondary ? (_jsx("p", { className: "text-xs text-zinc-500 dark:text-zinc-400", children: r.secondary })) : null, _jsx("span", { className: `inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${PRIORITY_BADGE[priority] ?? PRIORITY_BADGE.normal}`, children: priority })] }));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: "status",
|
|
135
|
+
header: "Status",
|
|
136
|
+
className: "w-36",
|
|
137
|
+
render: (row) => {
|
|
138
|
+
const r = row;
|
|
139
|
+
return (_jsx("span", { className: `inline-flex rounded-full px-2.5 py-1 text-xs font-medium ${STATUS_BADGE[r.status] ?? STATUS_BADGE.open}`, children: r.status.replace(/_/g, " ") }));
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
key: "updatedAt",
|
|
144
|
+
header: "Updated",
|
|
145
|
+
className: "w-32",
|
|
146
|
+
render: (row) => (_jsx("span", { className: "text-sm text-zinc-500 dark:text-zinc-400", children: row.updatedAt })),
|
|
147
|
+
},
|
|
148
|
+
] })] }), filterOpen && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-40 bg-black/40", "aria-hidden": "true", onClick: () => setFilterOpen(false) }), _jsxs("div", { className: "fixed inset-y-0 left-0 z-50 flex w-80 flex-col bg-white dark:bg-slate-900 shadow-2xl", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: [_jsx("span", { className: "text-base font-semibold text-zinc-900 dark:text-zinc-100", children: "Filters" }), _jsxs("div", { className: "flex items-center gap-2", children: [activeFilterCount > 0 && (_jsx("button", { type: "button", onClick: clearFilters, className: "text-xs text-zinc-500 hover:text-rose-500 dark:text-zinc-400 transition-colors", children: "Clear all" })), _jsx("button", { type: "button", onClick: () => setFilterOpen(false), "aria-label": "Close", className: "rounded-lg p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-slate-800 transition-colors", children: _jsx(X, { className: "h-5 w-5" }) })] })] }), _jsxs("div", { className: "flex-1 overflow-y-auto px-4 py-4 space-y-5", children: [_jsx(FilterChipGroup, { label: "Status", tabs: ADMIN_SUPPORT_TICKET_STATUS_TABS, value: pendingFilters.status ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, status: id })) }), _jsx(FilterChipGroup, { label: "Priority", tabs: ADMIN_SUPPORT_TICKET_PRIORITY_TABS, value: pendingFilters.priority ?? "", onChange: (id) => setPendingFilters((p) => ({ ...p, priority: id })) })] }), _jsx("div", { className: "border-t border-zinc-200 dark:border-slate-700 px-4 py-3.5", children: _jsxs("button", { type: "button", onClick: applyFilters, className: "w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-white hover:bg-primary-600 transition-colors active:scale-[0.98]", children: ["Apply Filters", activeFilterCount > 0 ? ` (${activeFilterCount})` : ""] }) })] })] }))] }), _jsx(AdminSupportTicketDetailView, { open: drawerOpen, onClose: () => setDrawerOpen(false), ticketId: selectedRow?.id, subject: selectedRow?.primary, userDisplayName: toStringValue(selectedRow?._raw?.userDisplayName, ""), category: toStringValue(selectedRow?._raw?.category, "general"), currentStatus: selectedRow?.status, currentPriority: toStringValue(selectedRow?._raw?.priority, "normal"), description: toStringValue(selectedRow?._raw?.description, ""), messages: Array.isArray(selectedRow?._raw?.messages)
|
|
149
|
+
? selectedRow._raw.messages
|
|
150
|
+
: [], internalNotes: toStringValue(selectedRow?._raw?.internalNotes, "") || undefined, orderId: toStringValue(selectedRow?._raw?.orderId, "") || undefined })] }));
|
|
151
|
+
}
|