feat: contract + referral adjustments

This commit is contained in:
DeathKaioken 2025-12-13 12:00:30 +01:00
parent ac358d4d7d
commit 0a8c570610
3 changed files with 65 additions and 88 deletions

View File

@ -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>

View File

@ -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]);

View File

@ -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