feat: contract + referral adjustments
This commit is contained in:
parent
ac358d4d7d
commit
0a8c570610
@ -15,7 +15,8 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
|
||||
const [lang, setLang] = useState<'en' | 'de'>('en');
|
||||
const [type, setType] = useState<string>('contract');
|
||||
const [type, setType] = useState<'contract' | 'bill' | 'other'>('contract');
|
||||
const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal');
|
||||
const [description, setDescription] = useState<string>('');
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
@ -94,10 +95,21 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
const slug = (s: string) =>
|
||||
s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'template';
|
||||
|
||||
// NEW: all-fields-required guard
|
||||
const canSave = Boolean(
|
||||
name.trim() &&
|
||||
htmlCode.trim() &&
|
||||
description.trim() &&
|
||||
type &&
|
||||
userType &&
|
||||
lang
|
||||
)
|
||||
|
||||
const save = async (publish: boolean) => {
|
||||
const html = htmlCode.trim();
|
||||
if (!name || !html) {
|
||||
setStatusMsg('Please enter a template name and content.');
|
||||
// NEW: validate all fields
|
||||
if (!canSave) {
|
||||
setStatusMsg('Please fill all required fields (name, HTML, type, user type, language, description).');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -113,7 +125,7 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
type,
|
||||
lang,
|
||||
description: description || undefined,
|
||||
user_type: 'both',
|
||||
user_type: userType,
|
||||
});
|
||||
|
||||
if (publish && created?.id) {
|
||||
@ -140,6 +152,7 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
placeholder="Template name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full sm:w-1/2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -164,16 +177,30 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
|
||||
{/* New metadata inputs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type (e.g., contract, nda, invoice)"
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
onChange={(e) => setType(e.target.value as 'contract' | 'bill' | 'other')}
|
||||
required
|
||||
className="w-full sm:w-1/3 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
>
|
||||
<option value="contract">Contract</option>
|
||||
<option value="bill">Bill</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<select
|
||||
value={userType}
|
||||
onChange={(e) => setUserType(e.target.value as 'personal' | 'company' | 'both')}
|
||||
required
|
||||
className="w-full sm:w-40 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
<option value="both">Both</option>
|
||||
</select>
|
||||
<select
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
|
||||
required
|
||||
className="w-full sm:w-32 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
>
|
||||
<option value="en">English (en)</option>
|
||||
@ -184,6 +211,7 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
placeholder="Description (optional)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
|
||||
/>
|
||||
</div>
|
||||
@ -194,6 +222,7 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
value={htmlCode}
|
||||
onChange={(e) => setHtmlCode(e.target.value)}
|
||||
placeholder="Paste your full HTML (or snippet) here…"
|
||||
required
|
||||
className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow"
|
||||
/>
|
||||
)}
|
||||
@ -212,18 +241,20 @@ export default function ContractEditor({ onSaved }: Props) {
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => save(false)}
|
||||
disabled={saving}
|
||||
disabled={saving || !canSave}
|
||||
className="inline-flex items-center rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-900 px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
||||
>
|
||||
Create (inactive)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => save(true)}
|
||||
disabled={saving}
|
||||
disabled={saving || !canSave}
|
||||
className="inline-flex items-center rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 text-sm font-medium shadow disabled:opacity-60 transition"
|
||||
>
|
||||
Create & Activate
|
||||
</button>
|
||||
{/* NEW: helper text */}
|
||||
{!canSave && <span className="text-xs text-red-600">Fill all fields to proceed.</span>}
|
||||
{saving && <span className="text-xs text-gray-500">Saving…</span>}
|
||||
{statusMsg && <span className="text-xs text-gray-600">{statusMsg}</span>}
|
||||
</div>
|
||||
|
||||
@ -164,7 +164,7 @@ export default function useContractManagement() {
|
||||
fd.append('type', payload.type);
|
||||
fd.append('lang', payload.lang);
|
||||
if (payload.description) fd.append('description', payload.description);
|
||||
if (payload.user_type) fd.append('user_type', payload.user_type);
|
||||
fd.append('user_type', (payload.user_type ?? 'both'));
|
||||
|
||||
return authorizedFetch<DocumentTemplate>('/api/document-templates', { method: 'POST', body: fd });
|
||||
}, [authorizedFetch]);
|
||||
|
||||
@ -4,7 +4,7 @@ import React, { useMemo, useState } from 'react'
|
||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type UserStatus = 'active' | 'inactive' | 'pending' | 'blocked' // CHANGED: add inactive
|
||||
type UserStatus = 'active' | 'inactive' | 'pending' | 'blocked'
|
||||
|
||||
export interface RegisteredUser {
|
||||
id: string | number
|
||||
@ -12,7 +12,7 @@ export interface RegisteredUser {
|
||||
email: string
|
||||
userType: UserType
|
||||
registeredAt: string | Date
|
||||
refCode?: string // CHANGED: optional, not used in UI
|
||||
refCode?: string
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
@ -21,50 +21,10 @@ interface Props {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// Base dummy set for the widget preview
|
||||
const baseUsers: RegisteredUser[] = [
|
||||
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', userType: 'personal', registeredAt: '2025-03-22T10:04:00Z', refCode: 'REF-9A2F1C', status: 'active' },
|
||||
{ id: 2, name: 'Beta GmbH', email: 'office@beta-gmbh.de', userType: 'company', registeredAt: '2025-03-20T08:31:00Z', refCode: 'REF-9A2F1C', status: 'pending' },
|
||||
{ id: 3, name: 'Carlos Diaz', email: 'carlos@sample.io', userType: 'personal', registeredAt: '2025-03-19T14:22:00Z', refCode: 'REF-77XZQ1', status: 'active' },
|
||||
{ id: 4, name: 'Delta Solutions AG', email: 'contact@delta.ag', userType: 'company', registeredAt: '2025-03-18T09:12:00Z', refCode: 'REF-77XZQ1', status: 'active' },
|
||||
{ id: 5, name: 'Emily Nguyen', email: 'emily.ng@ex.com', userType: 'personal', registeredAt: '2025-03-17T19:44:00Z', refCode: 'REF-9A2F1C', status: 'blocked' },
|
||||
]
|
||||
|
||||
// Expanded dummy list for the modal (pagination/search/filter/export demo)
|
||||
function buildDummyAll(): RegisteredUser[] {
|
||||
const list: RegisteredUser[] = []
|
||||
let id = 1
|
||||
const refCodes = ['REF-9A2F1C', 'REF-77XZQ1', 'REF-55PLK9']
|
||||
const names = [
|
||||
'Alice Johnson', 'Beta GmbH', 'Carlos Diaz', 'Delta Solutions AG', 'Emily Nguyen',
|
||||
'Foxtrot LLC', 'Green Innovations', 'Helios Corp', 'Ivy Partners', 'Jonas Weber'
|
||||
]
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const name = names[i % names.length]
|
||||
const isCompany = /GmbH|AG|LLC|Corp|Partners|Innovations/.test(name) ? 'company' : 'personal'
|
||||
const status: UserStatus = (i % 9 === 0) ? 'blocked' : (i % 3 === 0 ? 'pending' : 'active')
|
||||
const refCode = refCodes[i % refCodes.length]
|
||||
const date = new Date(2025, 2, 28 - (i % 25), 10 + (i % 12), (i * 7) % 60, 0).toISOString()
|
||||
list.push({
|
||||
id: id++,
|
||||
name,
|
||||
email: `${name.toLowerCase().replace(/[^a-z]+/g, '.')}@example.com`,
|
||||
userType: isCompany as UserType,
|
||||
registeredAt: date,
|
||||
refCode,
|
||||
status
|
||||
})
|
||||
}
|
||||
// newest first
|
||||
return list.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}
|
||||
|
||||
const allDummyUsers = buildDummyAll()
|
||||
|
||||
function statusBadgeClass(s: UserStatus) {
|
||||
switch (s) {
|
||||
case 'active': return 'bg-green-100 text-green-800'
|
||||
case 'inactive': return 'bg-gray-100 text-gray-800' // NEW
|
||||
case 'inactive': return 'bg-gray-100 text-gray-800'
|
||||
case 'pending': return 'bg-amber-100 text-amber-800'
|
||||
case 'blocked': return 'bg-rose-100 text-rose-800'
|
||||
default: return 'bg-slate-100 text-slate-800'
|
||||
@ -75,9 +35,9 @@ function typeBadgeClass(t: UserType) {
|
||||
return t === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
||||
}
|
||||
|
||||
// CSV export helper (no ref code)
|
||||
// CSV export helper
|
||||
function exportCsv(rows: RegisteredUser[]) {
|
||||
const header = ['Name', 'Email', 'Type', 'Registered', 'Status'] // CHANGED
|
||||
const header = ['Name', 'Email', 'Type', 'Registered', 'Status']
|
||||
const csv = [
|
||||
header.join(','),
|
||||
...rows.map(r => [
|
||||
@ -99,28 +59,28 @@ function exportCsv(rows: RegisteredUser[]) {
|
||||
}
|
||||
|
||||
export default function RegisteredUserList({ users, loading }: Props) {
|
||||
// Normalize backend shape to local RegisteredUser shape (type/status/registeredAt)
|
||||
// Normalize backend shape to local RegisteredUser shape
|
||||
const normalizedUsers = useMemo<RegisteredUser[]>(() => {
|
||||
if (!users || users.length === 0) return []
|
||||
return users.map((u: any) => ({
|
||||
...u,
|
||||
userType: u.userType ?? u.type ?? 'personal',
|
||||
status: (u.status ?? 'inactive') as UserStatus, // treat null/undefined as inactive
|
||||
id: u.id ?? u._id ?? u.userId ?? '',
|
||||
name: u.name ?? u.fullName ?? u.companyName ?? u.email ?? '',
|
||||
email: u.email ?? '',
|
||||
userType: (u.userType ?? u.type ?? 'personal') as UserType,
|
||||
registeredAt: u.registeredAt ?? u.createdAt ?? new Date().toISOString(),
|
||||
status: (u.status ?? 'inactive') as UserStatus,
|
||||
}))
|
||||
}, [users])
|
||||
|
||||
// Main widget rows (latest 5) - use normalized when provided, else dummy
|
||||
// Latest 5 rows only from backend
|
||||
const sorted = useMemo(() => {
|
||||
const data = (normalizedUsers.length > 0 ? normalizedUsers : baseUsers).slice()
|
||||
const data = normalizedUsers.slice()
|
||||
return data.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}, [normalizedUsers])
|
||||
const rows = sorted.slice(0, 5)
|
||||
|
||||
// Total badge: prefer normalized list length, else dummy all
|
||||
const totalRegistered = useMemo(() => {
|
||||
return normalizedUsers.length ? normalizedUsers.length : allDummyUsers.length
|
||||
}, [normalizedUsers])
|
||||
// Total badge: from backend list length
|
||||
const totalRegistered = normalizedUsers.length
|
||||
|
||||
// Modal state
|
||||
const [open, setOpen] = useState(false)
|
||||
@ -130,10 +90,9 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 10
|
||||
|
||||
// Full dataset for modal
|
||||
// Full dataset for modal (backend only)
|
||||
const allRows = useMemo(() => {
|
||||
const data = normalizedUsers.length ? normalizedUsers : allDummyUsers
|
||||
return data.sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
return normalizedUsers.slice().sort((a, b) => new Date(b.registeredAt).getTime() - new Date(a.registeredAt).getTime())
|
||||
}, [normalizedUsers])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@ -157,7 +116,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
// NEW: lock page scroll while modal is open
|
||||
// Lock scroll when modal open
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
@ -171,7 +130,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Registered Users via Your Referral</h2>
|
||||
{/* Moved badge directly under the title */}
|
||||
<div className="mt-2 inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-2 rounded-full bg-violet-100 text-violet-800 px-3 py-1 text-[11px] font-semibold tracking-wide">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
@ -188,7 +146,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
Showing the latest 5 users. Use “View all” to see the complete list.
|
||||
</p>
|
||||
</div>
|
||||
{/* ...existing code... */}
|
||||
<button
|
||||
onClick={resetAndOpen}
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500"
|
||||
@ -205,7 +162,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
{/* REMOVED: Ref Code column */}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -219,7 +175,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
||||
No registered users found for your referral links.
|
||||
No registered users found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@ -235,7 +191,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-700">{date}</td>
|
||||
{/* REMOVED: Ref Code cell */}
|
||||
<td className="px-6 py-4 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
||||
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||
@ -253,11 +208,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
{/* Modal with full list */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} aria-hidden />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-6xl bg-white rounded-xl shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-3">
|
||||
@ -281,12 +232,11 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-6 py-4 border-b border-gray-100 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
||||
placeholder="Search name or email…" // CHANGED
|
||||
placeholder="Search name or email…"
|
||||
className="md:col-span-2 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
||||
/>
|
||||
<select
|
||||
@ -305,13 +255,12 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option> {/* NEW */}
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
@ -320,7 +269,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Registered</th>
|
||||
{/* REMOVED: Ref Code column */}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -344,7 +292,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-700">{date}</td>
|
||||
{/* REMOVED: Ref Code cell */}
|
||||
<td className="px-6 py-3 text-sm">
|
||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadgeClass(u.status)}`}>
|
||||
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||
@ -358,7 +305,6 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
||||
<span className="text-xs text-gray-600">
|
||||
Showing {pageRows.length} of {filtered.length} users
|
||||
|
||||
Loading…
Reference in New Issue
Block a user