.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
e769132f84
commit
646c293bc1
@ -0,0 +1,247 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type MailTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOCK_MAIL_TEMPLATES: MailTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 'welcome',
|
||||||
|
name: 'Welcome Mail',
|
||||||
|
subject: 'Welcome to Profit Planet',
|
||||||
|
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>Welcome {{firstName}}</h2><p>Thanks for joining Profit Planet. We are happy to have you onboard.</p><p>Best regards,<br/>Profit Planet Team</p></div>',
|
||||||
|
updatedAt: '2026-05-04T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'invoice-reminder',
|
||||||
|
name: 'Invoice Reminder',
|
||||||
|
subject: 'Friendly reminder: invoice {{invoiceNumber}}',
|
||||||
|
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>Invoice Reminder</h2><p>Hello {{companyName}},</p><p>your invoice <strong>{{invoiceNumber}}</strong> is due on <strong>{{dueDate}}</strong>.</p><p>Thank you!</p></div>',
|
||||||
|
updatedAt: '2026-05-03T14:30:00.000Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MailTemplatesManager() {
|
||||||
|
const [mailTemplates, setMailTemplates] = useState<MailTemplate[]>(MOCK_MAIL_TEMPLATES);
|
||||||
|
const [selectedMailTemplateId, setSelectedMailTemplateId] = useState<string>(MOCK_MAIL_TEMPLATES[0]?.id ?? '');
|
||||||
|
const [isCreatingMailTemplate, setIsCreatingMailTemplate] = useState(false);
|
||||||
|
const [mailTemplateSavedMessage, setMailTemplateSavedMessage] = useState<string | null>(null);
|
||||||
|
const [mailEditor, setMailEditor] = useState<{ name: string; subject: string; html: string }>({
|
||||||
|
name: MOCK_MAIL_TEMPLATES[0]?.name ?? '',
|
||||||
|
subject: MOCK_MAIL_TEMPLATES[0]?.subject ?? '',
|
||||||
|
html: MOCK_MAIL_TEMPLATES[0]?.html ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedMailTemplate = mailTemplates.find((template) => template.id === selectedMailTemplateId) ?? null;
|
||||||
|
|
||||||
|
const startCreateMailTemplate = () => {
|
||||||
|
setIsCreatingMailTemplate(true);
|
||||||
|
setSelectedMailTemplateId('');
|
||||||
|
setMailEditor({
|
||||||
|
name: 'New Mail Template',
|
||||||
|
subject: '',
|
||||||
|
html: '<div style="font-family:Arial,sans-serif;line-height:1.5;"><h2>New Template</h2><p>Edit this HTML content.</p></div>',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectExistingMailTemplate = (id: string) => {
|
||||||
|
const template = mailTemplates.find((item) => item.id === id);
|
||||||
|
if (!template) return;
|
||||||
|
setIsCreatingMailTemplate(false);
|
||||||
|
setSelectedMailTemplateId(id);
|
||||||
|
setMailEditor({
|
||||||
|
name: template.name,
|
||||||
|
subject: template.subject,
|
||||||
|
html: template.html,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveMailTemplate = () => {
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
if (isCreatingMailTemplate) {
|
||||||
|
const generatedId = `mail-${Date.now()}`;
|
||||||
|
const nextTemplate: MailTemplate = {
|
||||||
|
id: generatedId,
|
||||||
|
name: mailEditor.name.trim() || 'Untitled Template',
|
||||||
|
subject: mailEditor.subject,
|
||||||
|
html: mailEditor.html,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
};
|
||||||
|
setMailTemplates((current) => [nextTemplate, ...current]);
|
||||||
|
setSelectedMailTemplateId(generatedId);
|
||||||
|
setIsCreatingMailTemplate(false);
|
||||||
|
setMailTemplateSavedMessage('Mail template created (mock).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedMailTemplateId) return;
|
||||||
|
|
||||||
|
setMailTemplates((current) =>
|
||||||
|
current.map((template) =>
|
||||||
|
template.id === selectedMailTemplateId
|
||||||
|
? {
|
||||||
|
...template,
|
||||||
|
name: mailEditor.name.trim() || template.name,
|
||||||
|
subject: mailEditor.subject,
|
||||||
|
html: mailEditor.html,
|
||||||
|
updatedAt: nowIso,
|
||||||
|
}
|
||||||
|
: template
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setMailTemplateSavedMessage('Mail template updated (mock).');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelectedMailTemplate = () => {
|
||||||
|
if (!selectedMailTemplateId) return;
|
||||||
|
|
||||||
|
setMailTemplates((current) => {
|
||||||
|
const next = current.filter((template) => template.id !== selectedMailTemplateId);
|
||||||
|
const nextSelected = next[0] ?? null;
|
||||||
|
setSelectedMailTemplateId(nextSelected?.id ?? '');
|
||||||
|
setIsCreatingMailTemplate(false);
|
||||||
|
setMailEditor({
|
||||||
|
name: nextSelected?.name ?? '',
|
||||||
|
subject: nextSelected?.subject ?? '',
|
||||||
|
html: nextSelected?.html ?? '',
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMailTemplateSavedMessage('Mail template removed (mock).');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:p-6 xl:p-7">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 text-xl font-semibold text-slate-900">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
|
||||||
|
Mail Templates
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startCreateMailTemplate}
|
||||||
|
className="inline-flex items-center rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
+ New Mail Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveMailTemplate}
|
||||||
|
className="inline-flex items-center rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-800 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={deleteSelectedMailTemplate}
|
||||||
|
disabled={isCreatingMailTemplate || !selectedMailTemplateId}
|
||||||
|
className="inline-flex items-center rounded-xl border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mb-5 text-sm text-slate-600">
|
||||||
|
Frontend-only mock editor. Data is stored in local component state and resets on page reload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{mailTemplateSavedMessage && (
|
||||||
|
<div className="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
|
{mailTemplateSavedMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[290px_minmax(0,1fr)]">
|
||||||
|
<aside className="rounded-2xl border border-slate-200 bg-white/80 p-3">
|
||||||
|
<h3 className="px-2 pb-2 text-sm font-semibold text-slate-800">Template List</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mailTemplates.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed border-slate-200 p-3 text-xs text-slate-500">
|
||||||
|
No templates yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{mailTemplates.map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectExistingMailTemplate(template.id)}
|
||||||
|
className={`w-full rounded-xl border px-3 py-2 text-left transition ${
|
||||||
|
!isCreatingMailTemplate && selectedMailTemplateId === template.id
|
||||||
|
? 'border-slate-900 bg-slate-900 text-white'
|
||||||
|
: 'border-slate-200 bg-white text-slate-800 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold truncate">{template.name}</div>
|
||||||
|
<div className={`mt-0.5 text-[11px] ${!isCreatingMailTemplate && selectedMailTemplateId === template.id ? 'text-slate-300' : 'text-slate-500'}`}>
|
||||||
|
{template.subject || 'No subject'}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Template Name</div>
|
||||||
|
<input
|
||||||
|
value={mailEditor.name}
|
||||||
|
onChange={(event) => setMailEditor((current) => ({ ...current, name: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="Welcome Mail"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">Subject</div>
|
||||||
|
<input
|
||||||
|
value={mailEditor.subject}
|
||||||
|
onChange={(event) => setMailEditor((current) => ({ ...current, subject: event.target.value }))}
|
||||||
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="Subject with placeholders like {{firstName}}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">HTML Body</div>
|
||||||
|
<textarea
|
||||||
|
value={mailEditor.html}
|
||||||
|
onChange={(event) => setMailEditor((current) => ({ ...current, html: event.target.value }))}
|
||||||
|
className="min-h-[220px] w-full rounded-lg border border-slate-200 px-3 py-2 text-sm font-mono text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
placeholder="<div>Your HTML here</div>"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/85 p-4">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-slate-900">Live Preview</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4">
|
||||||
|
<div className="mb-3 text-xs text-slate-500">
|
||||||
|
Subject: <span className="font-medium text-slate-800">{mailEditor.subject || 'No subject'}</span>
|
||||||
|
{selectedMailTemplate && !isCreatingMailTemplate && (
|
||||||
|
<span className="ml-3">Last update: {new Date(selectedMailTemplate.updatedAt).toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="prose prose-sm max-w-none text-slate-800"
|
||||||
|
dangerouslySetInnerHTML={{ __html: mailEditor.html || '<p class="text-slate-500">No HTML content</p>' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import ContractEditor from './components/contractEditor';
|
|||||||
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
import ContractUploadCompanyStamp from './components/contractUploadCompanyStamp';
|
||||||
import CompanySettingsPanel from './components/companySettingsPanel';
|
import CompanySettingsPanel from './components/companySettingsPanel';
|
||||||
import ContractTemplateList from './components/contractTemplateList';
|
import ContractTemplateList from './components/contractTemplateList';
|
||||||
|
import MailTemplatesManager from './components/mailTemplatesManager';
|
||||||
import useAuthStore from '../../store/authStore';
|
import useAuthStore from '../../store/authStore';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ import { useTranslation } from '../../i18n/useTranslation';
|
|||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
|
{ key: 'stamp', label: 'Company Stamp', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg> },
|
||||||
|
{ key: 'mailTemplates', label: 'Mail Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7l9 6 9-6M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> },
|
||||||
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
|
{ key: 'templates', label: 'Templates', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M4 6h16M4 12h16M4 18h16"/></svg> },
|
||||||
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
|
{ key: 'editor', label: 'Create Template', icon: <svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 113 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> },
|
||||||
];
|
];
|
||||||
@ -121,6 +123,9 @@ export default function ContractManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{section === 'mailTemplates' && (
|
||||||
|
<MailTemplatesManager />
|
||||||
|
)}
|
||||||
{section === 'templates' && (
|
{section === 'templates' && (
|
||||||
<section className="rounded-[28px] border border-white/80 bg-white/60 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur md:p-5 xl:p-6">
|
<section className="rounded-[28px] border border-white/80 bg-white/60 p-4 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur md:p-5 xl:p-6">
|
||||||
<ContractTemplateList
|
<ContractTemplateList
|
||||||
|
|||||||
@ -12,8 +12,10 @@ import {
|
|||||||
type BackendPlatform = {
|
type BackendPlatform = {
|
||||||
id: string | number
|
id: string | number
|
||||||
title: string
|
title: string
|
||||||
|
titleKey?: string | null
|
||||||
href: string
|
href: string
|
||||||
description?: string | null
|
description?: string | null
|
||||||
|
descriptionKey?: string | null
|
||||||
icon?: DashboardPlatformIconName | null
|
icon?: DashboardPlatformIconName | null
|
||||||
color?: DashboardPlatformColorClass | null
|
color?: DashboardPlatformColorClass | null
|
||||||
state?: boolean
|
state?: boolean
|
||||||
@ -46,7 +48,9 @@ function toRow(p: BackendPlatform): PlatformRow {
|
|||||||
return {
|
return {
|
||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
title: typeof p.title === 'string' ? p.title : '',
|
title: typeof p.title === 'string' ? p.title : '',
|
||||||
|
titleKey: typeof p.titleKey === 'string' ? p.titleKey : undefined,
|
||||||
description: typeof p.description === 'string' ? p.description : '',
|
description: typeof p.description === 'string' ? p.description : '',
|
||||||
|
descriptionKey: typeof p.descriptionKey === 'string' ? p.descriptionKey : undefined,
|
||||||
href: typeof p.href === 'string' ? p.href : '',
|
href: typeof p.href === 'string' ? p.href : '',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass),
|
color: (p.color as DashboardPlatformColorClass) || ('bg-blue-500' as DashboardPlatformColorClass),
|
||||||
@ -59,8 +63,10 @@ function toRow(p: BackendPlatform): PlatformRow {
|
|||||||
function toPayload(p: PlatformRow) {
|
function toPayload(p: PlatformRow) {
|
||||||
return {
|
return {
|
||||||
title: p.title,
|
title: p.title,
|
||||||
|
titleKey: p.titleKey ?? '',
|
||||||
href: p.href,
|
href: p.href,
|
||||||
description: p.description ?? '',
|
description: p.description ?? '',
|
||||||
|
descriptionKey: p.descriptionKey ?? '',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
state: Boolean(p.isActive),
|
state: Boolean(p.isActive),
|
||||||
@ -72,8 +78,10 @@ function toPayload(p: PlatformRow) {
|
|||||||
function normalizeForCompare(p: PlatformRow) {
|
function normalizeForCompare(p: PlatformRow) {
|
||||||
return {
|
return {
|
||||||
title: (p.title || '').trim(),
|
title: (p.title || '').trim(),
|
||||||
|
titleKey: (p.titleKey || '').trim(),
|
||||||
href: (p.href || '').trim(),
|
href: (p.href || '').trim(),
|
||||||
description: (p.description || '').trim(),
|
description: (p.description || '').trim(),
|
||||||
|
descriptionKey: (p.descriptionKey || '').trim(),
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: p.color,
|
color: p.color,
|
||||||
isActive: Boolean(p.isActive),
|
isActive: Boolean(p.isActive),
|
||||||
@ -90,8 +98,10 @@ function isChanged(p: PlatformRow, baselineById: Record<string, PlatformRow>): b
|
|||||||
const b = normalizeForCompare(baseline)
|
const b = normalizeForCompare(baseline)
|
||||||
return (
|
return (
|
||||||
a.title !== b.title ||
|
a.title !== b.title ||
|
||||||
|
a.titleKey !== b.titleKey ||
|
||||||
a.href !== b.href ||
|
a.href !== b.href ||
|
||||||
a.description !== b.description ||
|
a.description !== b.description ||
|
||||||
|
a.descriptionKey !== b.descriptionKey ||
|
||||||
a.color !== b.color ||
|
a.color !== b.color ||
|
||||||
a.isActive !== b.isActive ||
|
a.isActive !== b.isActive ||
|
||||||
a.disabled !== b.disabled ||
|
a.disabled !== b.disabled ||
|
||||||
@ -112,7 +122,10 @@ export function useAdminDashboardPlatforms() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const hasValidationErrors = useMemo(() => {
|
const hasValidationErrors = useMemo(() => {
|
||||||
return platforms.some(p => !p.title.trim() || !p.href.trim() || !isValidHref(p.href))
|
return platforms.some(p => {
|
||||||
|
const hasTitle = Boolean(p.title?.trim()) || Boolean(p.titleKey?.trim())
|
||||||
|
return !hasTitle || !p.href.trim() || !isValidHref(p.href)
|
||||||
|
})
|
||||||
}, [platforms])
|
}, [platforms])
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
@ -152,7 +165,9 @@ export function useAdminDashboardPlatforms() {
|
|||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
title: 'New Platform',
|
title: 'New Platform',
|
||||||
|
titleKey: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
descriptionKey: '',
|
||||||
href: '/dashboard',
|
href: '/dashboard',
|
||||||
icon: FIXED_ICON,
|
icon: FIXED_ICON,
|
||||||
color: 'bg-blue-500' as DashboardPlatformColorClass,
|
color: 'bg-blue-500' as DashboardPlatformColorClass,
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import { useState } from 'react'
|
|||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import {
|
import {
|
||||||
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
|
DASHBOARD_PLATFORMS_COLOR_OPTIONS,
|
||||||
type DashboardPlatform,
|
|
||||||
type DashboardPlatformColorClass
|
type DashboardPlatformColorClass
|
||||||
} from '../../utils/dashboardPlatforms'
|
} from '../../utils/dashboardPlatforms'
|
||||||
import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
|
import { PlusIcon, TrashIcon, CheckIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
|
||||||
@ -43,15 +42,24 @@ export default function AdminDashboardManagementPage() {
|
|||||||
|
|
||||||
const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew)
|
const isOpen = (p: PlatformRow) => Boolean(openById[p.id] ?? p._isNew)
|
||||||
|
|
||||||
|
const translatePreview = (key: string | undefined, fallback: string) => {
|
||||||
|
const trimmedKey = (key || '').trim()
|
||||||
|
if (!trimmedKey) return fallback
|
||||||
|
const translated = t(trimmedKey)
|
||||||
|
return translated === trimmedKey ? (fallback || trimmedKey) : translated
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[#f6f8ff] via-[#f3f7ff] to-[#eef4ff]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 md:py-10">
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_12%_18%,rgba(59,130,246,0.16),transparent_38%),radial-gradient(circle_at_88%_0%,rgba(13,148,136,0.14),transparent_36%),radial-gradient(circle_at_76%_82%,rgba(245,158,11,0.12),transparent_30%)]" />
|
||||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-6 sm:p-8">
|
<div className="relative max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-6 md:py-10">
|
||||||
|
<div className="rounded-[30px] border border-white/80 bg-white/85 p-5 shadow-[0_30px_80px_-42px_rgba(15,23,42,0.35)] backdrop-blur-md sm:p-8">
|
||||||
<header className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
<header className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kc4315932')}</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">{t('autofix.k09546aee')}</div>
|
||||||
<p className="text-sm sm:text-base text-blue-700 mt-2">{t('autofix.k098ec0b9')}</p>
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.kc4315932')}</h1>
|
||||||
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">{t('autofix.k098ec0b9')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
<div className="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||||
@ -59,7 +67,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={addAndOpen}
|
onClick={addAndOpen}
|
||||||
disabled={loading || saving}
|
disabled={loading || saving}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-blue-900 text-white px-4 py-2 text-sm font-semibold hover:bg-blue-800"
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-900 text-white px-4 py-2 text-sm font-semibold hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-5 w-5" />{t('autofix.k39e2c5db')}</button>
|
<PlusIcon className="h-5 w-5" />{t('autofix.k39e2c5db')}</button>
|
||||||
<button
|
<button
|
||||||
@ -68,8 +76,8 @@ export default function AdminDashboardManagementPage() {
|
|||||||
disabled={hasValidationErrors || loading || saving}
|
disabled={hasValidationErrors || loading || saving}
|
||||||
className={
|
className={
|
||||||
hasValidationErrors || loading || saving
|
hasValidationErrors || loading || saving
|
||||||
? 'inline-flex items-center justify-center gap-2 rounded-lg bg-gray-300 text-gray-600 px-4 py-2 text-sm font-semibold cursor-not-allowed'
|
? 'inline-flex items-center justify-center gap-2 rounded-2xl bg-slate-200 text-slate-500 px-4 py-2 text-sm font-semibold cursor-not-allowed'
|
||||||
: 'inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-600 text-white px-4 py-2 text-sm font-semibold hover:bg-emerald-500'
|
: 'inline-flex items-center justify-center gap-2 rounded-2xl bg-[#8D6B1D] text-white px-4 py-2 text-sm font-semibold hover:bg-[#7A5E1A]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<CheckIcon className="h-5 w-5" />
|
<CheckIcon className="h-5 w-5" />
|
||||||
@ -79,42 +87,42 @@ export default function AdminDashboardManagementPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-800 backdrop-blur-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{savedAt && (
|
{savedAt && (
|
||||||
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
<div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50/90 px-4 py-3 text-sm text-emerald-800 backdrop-blur-sm">
|
||||||
Saved at {new Date(savedAt).toLocaleTimeString('de-DE')}
|
Saved at {new Date(savedAt).toLocaleTimeString('de-DE')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasValidationErrors && (
|
{hasValidationErrors && (
|
||||||
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50/90 px-4 py-3 text-sm text-amber-900 backdrop-blur-sm">
|
||||||
Please ensure every platform has a title and a valid link (must start with “/” or “http(s)://”).
|
Please ensure every platform has a title or title key and a valid link (must start with “/” or “http(s)://”).
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 text-sm text-gray-600">{t('autofix.k832387c5')}</div>
|
<div className="rounded-[24px] border border-white/80 bg-white/85 p-6 text-sm text-slate-600 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)]">{t('autofix.k832387c5')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && platforms.map(platform => (
|
{!loading && platforms.map(platform => (
|
||||||
<div key={platform.id} className="rounded-2xl bg-white border border-gray-100 shadow p-4 sm:p-5">
|
<div key={platform.id} className="rounded-[24px] border border-white/80 bg-white/85 p-4 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)] backdrop-blur-md sm:p-5">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-base font-semibold text-gray-900 truncate">{platform.title}</div>
|
<div className="text-base font-semibold text-slate-900 break-words">{translatePreview(platform.titleKey, platform.title) || t('autofix.k39e2c5db')}</div>
|
||||||
<div className="text-xs text-gray-500 truncate">{platform.href}</div>
|
<div className="text-xs text-slate-500 break-all">{platform.href}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleOpen(platform.id)}
|
onClick={() => toggleOpen(platform.id)}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-white text-gray-800 px-3 py-2 text-xs font-semibold hover:bg-gray-50"
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white text-slate-800 px-3 py-2 text-xs font-semibold hover:bg-slate-50 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isOpen(platform) ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
{isOpen(platform) ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
|
||||||
{isOpen(platform) ? 'Close' : 'Edit'}
|
{isOpen(platform) ? 'Close' : 'Edit'}
|
||||||
@ -130,7 +138,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
await setPlatformState(platform, false)
|
await setPlatformState(platform, false)
|
||||||
}}
|
}}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs font-semibold hover:bg-red-100"
|
className="inline-flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-xs font-semibold hover:bg-red-100 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
{platform._isNew ? 'Remove' : 'Deactivate'}
|
{platform._isNew ? 'Remove' : 'Deactivate'}
|
||||||
@ -139,63 +147,87 @@ export default function AdminDashboardManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOpen(platform) && (
|
{isOpen(platform) && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 rounded-2xl border border-white/80 bg-white/80 p-4">
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kd96b6952')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.titleKey || ''}
|
||||||
|
onChange={e => updatePlatform(platform.id, { titleKey: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
placeholder="dashboard.platformCards.custom.title"
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 break-words">Preview: {translatePreview(platform.titleKey, platform.title)}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Title</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k339260c9')}</div>
|
||||||
<input
|
<input
|
||||||
value={platform.title}
|
value={platform.title}
|
||||||
onChange={e => updatePlatform(platform.id, { title: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { title: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="block">
|
|
||||||
<div className="text-xs font-semibold text-gray-700">Description</div>
|
|
||||||
<input
|
|
||||||
value={platform.description}
|
|
||||||
onChange={e => updatePlatform(platform.id, { description: e.target.value })}
|
|
||||||
disabled={saving}
|
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block md:col-span-2">
|
<label className="block md:col-span-2">
|
||||||
<div className="text-xs font-semibold text-gray-700">Link</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k2c0cfef4')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.descriptionKey || ''}
|
||||||
|
onChange={e => updatePlatform(platform.id, { descriptionKey: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
placeholder="dashboard.platformCards.custom.description"
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
<div className="mt-1 text-xs text-slate-500 break-words">Preview: {translatePreview(platform.descriptionKey, platform.description)}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.k4a292bef')}</div>
|
||||||
|
<input
|
||||||
|
value={platform.description}
|
||||||
|
onChange={e => updatePlatform(platform.id, { description: e.target.value })}
|
||||||
|
disabled={saving}
|
||||||
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block md:col-span-2">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Link</div>
|
||||||
<input
|
<input
|
||||||
value={platform.href}
|
value={platform.href}
|
||||||
onChange={e => updatePlatform(platform.id, { href: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { href: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
placeholder={t('autofix.k17f65c37')}
|
placeholder={t('autofix.k17f65c37')}
|
||||||
className={
|
className={
|
||||||
'mt-1 w-full rounded-lg border px-3 py-2 text-sm ' +
|
'mt-1 w-full rounded-2xl border bg-white px-3 py-2 text-sm text-slate-900 ' +
|
||||||
(isValidHref(platform.href) ? 'border-gray-200' : 'border-red-300')
|
(isValidHref(platform.href) ? 'border-slate-200' : 'border-red-300')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isValidHref(platform.href) && (
|
{!isValidHref(platform.href) && (
|
||||||
<div className="mt-1 text-xs text-red-600">Must start with “/” or “http(s)://”.</div>
|
<div className="mt-1 text-xs text-red-600">Must start with “/” or “http(s)://”.</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-1 text-xs text-gray-500">
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
Use a relative path (starts with “/”) for internal pages, or a full URL for external pages.
|
Use a relative path (starts with “/”) for internal pages, or a full URL for external pages.
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Icon</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Icon</div>
|
||||||
<input
|
<input
|
||||||
value={'Link'}
|
value={'Link'}
|
||||||
disabled
|
disabled
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<div className="text-xs font-semibold text-gray-700">Color</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">Color</div>
|
||||||
<select
|
<select
|
||||||
value={platform.color}
|
value={platform.color}
|
||||||
onChange={e => updatePlatform(platform.id, { color: e.target.value as DashboardPlatformColorClass })}
|
onChange={e => updatePlatform(platform.id, { color: e.target.value as DashboardPlatformColorClass })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
>
|
>
|
||||||
{DASHBOARD_PLATFORMS_COLOR_OPTIONS.map(opt => (
|
{DASHBOARD_PLATFORMS_COLOR_OPTIONS.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
@ -204,7 +236,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 md:col-span-2">
|
<div className="flex flex-wrap gap-4 md:col-span-2">
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={platform.isActive}
|
checked={platform.isActive}
|
||||||
@ -214,7 +246,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
Active (visible on dashboard)
|
Active (visible on dashboard)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm">
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={Boolean(platform.disabled)}
|
checked={Boolean(platform.disabled)}
|
||||||
@ -227,12 +259,12 @@ export default function AdminDashboardManagementPage() {
|
|||||||
|
|
||||||
{platform.disabled && (
|
{platform.disabled && (
|
||||||
<label className="block md:col-span-2">
|
<label className="block md:col-span-2">
|
||||||
<div className="text-xs font-semibold text-gray-700">{t('autofix.kab99811e')}</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-600">{t('autofix.kab99811e')}</div>
|
||||||
<input
|
<input
|
||||||
value={platform.disabledText || ''}
|
value={platform.disabledText || ''}
|
||||||
onChange={e => updatePlatform(platform.id, { disabledText: e.target.value })}
|
onChange={e => updatePlatform(platform.id, { disabledText: e.target.value })}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
className="mt-1 w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@ -244,7 +276,7 @@ export default function AdminDashboardManagementPage() {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{!loading && platforms.length === 0 && (
|
{!loading && platforms.length === 0 && (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-600">{t('autofix.kbce9fbea')}</div>
|
<div className="rounded-[24px] border border-white/80 bg-white/85 p-8 text-center text-sm text-slate-600 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.3)]">{t('autofix.kbce9fbea')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -0,0 +1,349 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { useVatRates } from './getTaxes'
|
||||||
|
import { useAdminInvoices, type AdminInvoice } from './getInvoices'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
type BillFilter = {
|
||||||
|
query: string
|
||||||
|
status: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFinanceManagementPageState() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const accessToken = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
||||||
|
|
||||||
|
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
||||||
|
const [billFilter, setBillFilter] = useState<BillFilter>({ query: '', status: 'issued', from: '', to: '' })
|
||||||
|
|
||||||
|
const [diagLoading, setDiagLoading] = useState(false)
|
||||||
|
const [diagError, setDiagError] = useState('')
|
||||||
|
const [diagData, setDiagData] = useState<any | null>(null)
|
||||||
|
|
||||||
|
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
||||||
|
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||||
|
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||||
|
const [reportEmail, setReportEmail] = useState('')
|
||||||
|
const [sendingReport, setSendingReport] = useState(false)
|
||||||
|
|
||||||
|
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||||
|
|
||||||
|
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
||||||
|
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
||||||
|
const [uploadForm, setUploadForm] = useState({
|
||||||
|
buyer_name: '',
|
||||||
|
buyer_email: '',
|
||||||
|
buyer_street: '',
|
||||||
|
buyer_postal_code: '',
|
||||||
|
buyer_city: '',
|
||||||
|
buyer_country: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
total_gross: '',
|
||||||
|
vat_rate: '20',
|
||||||
|
status: 'issued',
|
||||||
|
issued_at: '',
|
||||||
|
due_at: '',
|
||||||
|
})
|
||||||
|
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { invoices, loading: invLoading, error: invError, reload } = useAdminInvoices({
|
||||||
|
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
||||||
|
limit: 200,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const inRange = (d: Date) => {
|
||||||
|
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
if (timeframe === '7d') return diff <= 7
|
||||||
|
if (timeframe === '30d') return diff <= 30
|
||||||
|
if (timeframe === '90d') return diff <= 90
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = invoices.filter((invoice) => {
|
||||||
|
const dateValue = invoice.issued_at ?? invoice.created_at
|
||||||
|
if (!dateValue) return false
|
||||||
|
return inRange(new Date(dateValue))
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAll: invoices.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0),
|
||||||
|
totalRange: range.reduce((sum, invoice) => sum + Number(invoice.total_gross ?? 0), 0),
|
||||||
|
}
|
||||||
|
}, [invoices, timeframe])
|
||||||
|
|
||||||
|
const filteredBills = useMemo(() => {
|
||||||
|
const query = billFilter.query.trim().toLowerCase()
|
||||||
|
const from = billFilter.from ? new Date(billFilter.from) : null
|
||||||
|
const to = billFilter.to ? new Date(billFilter.to) : null
|
||||||
|
|
||||||
|
return invoices.filter((invoice) => {
|
||||||
|
const byQuery =
|
||||||
|
!query ||
|
||||||
|
String(invoice.invoice_number ?? invoice.id).toLowerCase().includes(query) ||
|
||||||
|
String(invoice.buyer_name ?? '').toLowerCase().includes(query)
|
||||||
|
|
||||||
|
const issuedAt = invoice.issued_at ? new Date(invoice.issued_at) : invoice.created_at ? new Date(invoice.created_at) : null
|
||||||
|
const byFrom = from ? (issuedAt ? issuedAt >= from : false) : true
|
||||||
|
const byTo = to ? (issuedAt ? issuedAt <= to : false) : true
|
||||||
|
|
||||||
|
return byQuery && byFrom && byTo
|
||||||
|
})
|
||||||
|
}, [invoices, billFilter])
|
||||||
|
|
||||||
|
const exportBills = (format: 'csv' | 'pdf') => {
|
||||||
|
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
||||||
|
alert(t('autofix.k6430ec9d').replace('{format}', format.toUpperCase()).replace('{count}', String(filteredBills.length)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPoolCheck = async (invoiceId: string | number) => {
|
||||||
|
setDiagLoading(true)
|
||||||
|
setDiagError('')
|
||||||
|
setDiagData(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
setDiagError(body?.message || t('autofix.k4d551f20').replace('{status}', String(response.status)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDiagData(body?.data || null)
|
||||||
|
} catch (error: any) {
|
||||||
|
setDiagError(error?.message || t('autofix.k84447f0f'))
|
||||||
|
} finally {
|
||||||
|
setDiagLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportInvoice = (invoice: AdminInvoice) => {
|
||||||
|
const pretty = JSON.stringify(invoice, null, 2)
|
||||||
|
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `invoice-${invoice.invoice_number || invoice.id}.json`
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewInvoicePdf = async (invoice: AdminInvoice) => {
|
||||||
|
setPdfLoading(invoice.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const response = await fetch(`${base}/api/invoices/${invoice.id}/pdf`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
throw new Error(body?.message || t('autofix.k01a04b9d').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
window.open(blobUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
} catch (error: any) {
|
||||||
|
setReportMsg({ type: 'error', text: error?.message || t('autofix.k6d4dfb53') })
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetUploadForm = () => {
|
||||||
|
setUploadForm({
|
||||||
|
buyer_name: '',
|
||||||
|
buyer_email: '',
|
||||||
|
buyer_street: '',
|
||||||
|
buyer_postal_code: '',
|
||||||
|
buyer_city: '',
|
||||||
|
buyer_country: '',
|
||||||
|
currency: 'EUR',
|
||||||
|
total_gross: '',
|
||||||
|
vat_rate: '20',
|
||||||
|
status: 'issued',
|
||||||
|
issued_at: '',
|
||||||
|
due_at: '',
|
||||||
|
})
|
||||||
|
setUploadFile(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitUploadInvoice = async () => {
|
||||||
|
if (!uploadForm.total_gross || isNaN(Number(uploadForm.total_gross))) {
|
||||||
|
setUploadError(t('autofix.k0f7cd409'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadError(null)
|
||||||
|
setUploading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const gross = parseFloat(uploadForm.total_gross)
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
const { total_gross, vat_rate, ...rest } = uploadForm
|
||||||
|
Object.entries(rest).forEach(([key, value]) => {
|
||||||
|
if (value !== '') formData.append(key, value)
|
||||||
|
})
|
||||||
|
formData.append('total_gross', gross.toFixed(2))
|
||||||
|
formData.append('total_net', net.toFixed(2))
|
||||||
|
formData.append('total_tax', tax.toFixed(2))
|
||||||
|
formData.append('vat_rate', String(rate))
|
||||||
|
if (uploadFile) formData.append('pdf', uploadFile)
|
||||||
|
|
||||||
|
const response = await fetch(`${base}/api/admin/invoices`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
throw new Error(body?.message || t('autofix.kecf550b9').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadModalOpen(false)
|
||||||
|
resetUploadForm()
|
||||||
|
reload()
|
||||||
|
setReportMsg({ type: 'success', text: t('autofix.k28165f23').replace('{invoice}', String(body.data?.invoice_number ?? '')) })
|
||||||
|
} catch (error: any) {
|
||||||
|
setUploadError(error?.message || t('autofix.k1e7317ac'))
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEmailReport = async () => {
|
||||||
|
if (!reportEmail.trim()) return
|
||||||
|
|
||||||
|
setReportMsg(null)
|
||||||
|
setSendingReport(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
||||||
|
const response = await fetch(`${base}/api/admin/invoices/email-report`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: reportEmail.trim(),
|
||||||
|
from: billFilter.from || undefined,
|
||||||
|
to: billFilter.to || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
if (!response.ok || body?.success === false) {
|
||||||
|
throw new Error(body?.message || t('autofix.k5a2f88b8').replace('{status}', String(response.status)))
|
||||||
|
}
|
||||||
|
|
||||||
|
setReportMsg({
|
||||||
|
type: 'success',
|
||||||
|
text: t('autofix.k5f4036ad')
|
||||||
|
.replace('{email}', reportEmail.trim())
|
||||||
|
.replace('{count}', String(body.data?.sentCount ?? 0)),
|
||||||
|
})
|
||||||
|
setEmailDialogOpen(false)
|
||||||
|
setReportEmail('')
|
||||||
|
} catch (error: any) {
|
||||||
|
setReportMsg({ type: 'error', text: error?.message || t('autofix.k3c6499f9') })
|
||||||
|
} finally {
|
||||||
|
setSendingReport(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadPreview = useMemo(() => {
|
||||||
|
const gross = parseFloat(uploadForm.total_gross) || 0
|
||||||
|
const rate = parseFloat(uploadForm.vat_rate) || 0
|
||||||
|
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
||||||
|
const tax = +(gross - net).toFixed(2)
|
||||||
|
return { net, tax }
|
||||||
|
}, [uploadForm.total_gross, uploadForm.vat_rate])
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
router,
|
||||||
|
rates,
|
||||||
|
vatLoading,
|
||||||
|
vatError,
|
||||||
|
timeframe,
|
||||||
|
setTimeframe,
|
||||||
|
billFilter,
|
||||||
|
setBillFilter,
|
||||||
|
diagLoading,
|
||||||
|
diagError,
|
||||||
|
diagData,
|
||||||
|
selectedInvoice,
|
||||||
|
setSelectedInvoice,
|
||||||
|
detailModalOpen,
|
||||||
|
setDetailModalOpen,
|
||||||
|
emailDialogOpen,
|
||||||
|
setEmailDialogOpen,
|
||||||
|
reportEmail,
|
||||||
|
setReportEmail,
|
||||||
|
sendingReport,
|
||||||
|
reportMsg,
|
||||||
|
setReportMsg,
|
||||||
|
invoices,
|
||||||
|
invLoading,
|
||||||
|
invError,
|
||||||
|
reload,
|
||||||
|
totals,
|
||||||
|
filteredBills,
|
||||||
|
exportBills,
|
||||||
|
runPoolCheck,
|
||||||
|
exportInvoice,
|
||||||
|
pdfLoading,
|
||||||
|
uploadModalOpen,
|
||||||
|
setUploadModalOpen,
|
||||||
|
uploadForm,
|
||||||
|
setUploadForm,
|
||||||
|
uploadFile,
|
||||||
|
setUploadFile,
|
||||||
|
uploading,
|
||||||
|
uploadError,
|
||||||
|
setUploadError,
|
||||||
|
viewInvoicePdf,
|
||||||
|
submitUploadInvoice,
|
||||||
|
sendEmailReport,
|
||||||
|
uploadPreview,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,394 +1,294 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
|
||||||
import React, { useMemo, useState } from 'react'
|
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { useVatRates } from './hooks/getTaxes'
|
|
||||||
import { useAdminInvoices, type AdminInvoice } from './hooks/getInvoices'
|
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
||||||
|
import { type AdminInvoice } from './hooks/getInvoices'
|
||||||
|
import { useFinanceManagementPageState } from './hooks/useFinanceManagementPageState'
|
||||||
|
|
||||||
|
function getStatusBadgeClass(status?: string) {
|
||||||
|
if (status === 'paid') return 'bg-green-100 text-green-700'
|
||||||
|
if (status === 'issued') return 'bg-indigo-100 text-indigo-700'
|
||||||
|
if (status === 'draft') return 'bg-slate-100 text-slate-700'
|
||||||
|
if (status === 'overdue') return 'bg-red-100 text-red-700'
|
||||||
|
return 'bg-amber-100 text-amber-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(t: (key: string) => string, status?: string) {
|
||||||
|
if (status === 'draft') return t('autofix.k5f6d9f11')
|
||||||
|
if (status === 'issued') return t('autofix.kdc8f2ab2')
|
||||||
|
if (status === 'paid') return t('autofix.k9d5b2d74')
|
||||||
|
if (status === 'overdue') return t('autofix.k2f44ec11')
|
||||||
|
if (status === 'canceled') return t('autofix.kcf31ed66')
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinanceInvoiceActions({
|
||||||
|
invoice,
|
||||||
|
pdfLoading,
|
||||||
|
onViewPdf,
|
||||||
|
onOpenDetails,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
invoice: AdminInvoice
|
||||||
|
pdfLoading: string | number | null
|
||||||
|
onViewPdf: (invoice: AdminInvoice) => void
|
||||||
|
onOpenDetails: (invoice: AdminInvoice) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<td className="px-3 py-2 space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewPdf(invoice)}
|
||||||
|
disabled={pdfLoading === invoice.id || !invoice.pdf_storage_key}
|
||||||
|
className="text-xs rounded-lg border border-slate-200 px-2.5 py-1.5 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{pdfLoading === invoice.id ? t('autofix.k79d12c2e') : t('autofix.kfbe29d11')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenDetails(invoice)}
|
||||||
|
className="text-xs rounded-lg border border-slate-200 px-2.5 py-1.5 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{t('autofix.kf67200af')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinanceManagementPage() {
|
export default function FinanceManagementPage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter()
|
|
||||||
const accessToken = useAuthStore(s => s.accessToken)
|
|
||||||
const { rates, loading: vatLoading, error: vatError } = useVatRates()
|
|
||||||
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
|
|
||||||
const [billFilter, setBillFilter] = useState({ query: '', status: 'issued', from: '', to: '' })
|
|
||||||
const [diagLoading, setDiagLoading] = useState(false)
|
|
||||||
const [diagError, setDiagError] = useState('')
|
|
||||||
const [diagData, setDiagData] = useState<any | null>(null)
|
|
||||||
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
|
||||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
|
||||||
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
|
||||||
const [reportEmail, setReportEmail] = useState('')
|
|
||||||
const [sendingReport, setSendingReport] = useState(false)
|
|
||||||
const [reportMsg, setReportMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
||||||
|
|
||||||
// NEW: fetch invoices from backend
|
|
||||||
const {
|
const {
|
||||||
invoices,
|
t,
|
||||||
loading: invLoading,
|
router,
|
||||||
error: invError,
|
rates,
|
||||||
|
vatLoading,
|
||||||
|
vatError,
|
||||||
|
timeframe,
|
||||||
|
setTimeframe,
|
||||||
|
billFilter,
|
||||||
|
setBillFilter,
|
||||||
|
diagLoading,
|
||||||
|
diagError,
|
||||||
|
diagData,
|
||||||
|
selectedInvoice,
|
||||||
|
setSelectedInvoice,
|
||||||
|
detailModalOpen,
|
||||||
|
setDetailModalOpen,
|
||||||
|
emailDialogOpen,
|
||||||
|
setEmailDialogOpen,
|
||||||
|
reportEmail,
|
||||||
|
setReportEmail,
|
||||||
|
sendingReport,
|
||||||
|
reportMsg,
|
||||||
|
setReportMsg,
|
||||||
|
invLoading,
|
||||||
|
invError,
|
||||||
reload,
|
reload,
|
||||||
} = useAdminInvoices({
|
totals,
|
||||||
status: billFilter.status !== 'all' ? billFilter.status : undefined,
|
filteredBills,
|
||||||
limit: 200,
|
exportBills,
|
||||||
offset: 0,
|
runPoolCheck,
|
||||||
})
|
exportInvoice,
|
||||||
|
pdfLoading,
|
||||||
// NEW: totals from backend invoices
|
uploadModalOpen,
|
||||||
const totals = useMemo(() => {
|
setUploadModalOpen,
|
||||||
const now = new Date()
|
uploadForm,
|
||||||
const inRange = (d: Date) => {
|
setUploadForm,
|
||||||
const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24)
|
uploadFile,
|
||||||
if (timeframe === '7d') return diff <= 7
|
setUploadFile,
|
||||||
if (timeframe === '30d') return diff <= 30
|
uploading,
|
||||||
if (timeframe === '90d') return diff <= 90
|
uploadError,
|
||||||
return true
|
setUploadError,
|
||||||
}
|
viewInvoicePdf,
|
||||||
const range = invoices.filter(inv => {
|
submitUploadInvoice,
|
||||||
const dStr = inv.issued_at ?? inv.created_at
|
sendEmailReport,
|
||||||
if (!dStr) return false
|
uploadPreview,
|
||||||
const d = new Date(dStr)
|
} = useFinanceManagementPageState()
|
||||||
return inRange(d)
|
|
||||||
})
|
|
||||||
const totalAll = invoices.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
||||||
const totalRange = range.reduce((s, inv) => s + Number(inv.total_gross ?? 0), 0)
|
|
||||||
return { totalAll, totalRange }
|
|
||||||
}, [invoices, timeframe])
|
|
||||||
|
|
||||||
// NEW: filtered rows for table
|
|
||||||
const filteredBills = useMemo(() => {
|
|
||||||
const q = billFilter.query.trim().toLowerCase()
|
|
||||||
const from = billFilter.from ? new Date(billFilter.from) : null
|
|
||||||
const to = billFilter.to ? new Date(billFilter.to) : null
|
|
||||||
|
|
||||||
return invoices.filter(inv => {
|
|
||||||
const byQuery =
|
|
||||||
!q ||
|
|
||||||
String(inv.invoice_number ?? inv.id).toLowerCase().includes(q) ||
|
|
||||||
String(inv.buyer_name ?? '').toLowerCase().includes(q)
|
|
||||||
const issued = inv.issued_at ? new Date(inv.issued_at) : (inv.created_at ? new Date(inv.created_at) : null)
|
|
||||||
const byFrom = from ? (issued ? issued >= from : false) : true
|
|
||||||
const byTo = to ? (issued ? issued <= to : false) : true
|
|
||||||
return byQuery && byFrom && byTo
|
|
||||||
})
|
|
||||||
}, [invoices, billFilter])
|
|
||||||
|
|
||||||
const exportBills = (format: 'csv' | 'pdf') => {
|
|
||||||
console.log('[export]', format, { filters: billFilter, invoices: filteredBills })
|
|
||||||
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const runPoolCheck = async (invoiceId: string | number) => {
|
|
||||||
setDiagLoading(true)
|
|
||||||
setDiagError('')
|
|
||||||
setDiagData(null)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok || body?.success === false) {
|
|
||||||
setDiagError(body?.message || `Check failed (${res.status})`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setDiagData(body?.data || null)
|
|
||||||
} catch (e: any) {
|
|
||||||
setDiagError(e?.message || 'Network error')
|
|
||||||
} finally {
|
|
||||||
setDiagLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportInvoice = (inv: AdminInvoice) => {
|
|
||||||
const pretty = JSON.stringify(inv, null, 2)
|
|
||||||
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `invoice-${inv.invoice_number || inv.id}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pdfLoading, setPdfLoading] = useState<string | number | null>(null)
|
|
||||||
const [uploadModalOpen, setUploadModalOpen] = useState(false)
|
|
||||||
const [uploadForm, setUploadForm] = useState({
|
|
||||||
buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '',
|
|
||||||
buyer_city: '', buyer_country: '', currency: 'EUR',
|
|
||||||
total_gross: '', vat_rate: '20',
|
|
||||||
status: 'issued', issued_at: '', due_at: '',
|
|
||||||
})
|
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const viewInvoicePdf = async (inv: AdminInvoice) => {
|
|
||||||
setPdfLoading(inv.id)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const res = await fetch(`${base}/api/invoices/${inv.id}/pdf`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(body?.message || `Failed to load PDF (${res.status})`)
|
|
||||||
}
|
|
||||||
const blob = await res.blob()
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
window.open(blobUrl, '_blank', 'noopener,noreferrer')
|
|
||||||
} catch (e: any) {
|
|
||||||
setReportMsg({ type: 'error', text: e?.message || 'Failed to load invoice PDF.' })
|
|
||||||
} finally {
|
|
||||||
setPdfLoading(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitUploadInvoice = async () => {
|
|
||||||
if (!uploadForm.total_gross || isNaN(Number(uploadForm.total_gross))) {
|
|
||||||
setUploadError('Total gross (Bruttobetrag) is required.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setUploadError(null)
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const gross = parseFloat(uploadForm.total_gross)
|
|
||||||
const rate = parseFloat(uploadForm.vat_rate) || 0
|
|
||||||
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
|
||||||
const tax = +(gross - net).toFixed(2)
|
|
||||||
const fd = new FormData()
|
|
||||||
const { total_gross, vat_rate, ...rest } = uploadForm
|
|
||||||
Object.entries(rest).forEach(([k, v]) => { if (v !== '') fd.append(k, v) })
|
|
||||||
fd.append('total_gross', gross.toFixed(2))
|
|
||||||
fd.append('total_net', net.toFixed(2))
|
|
||||||
fd.append('total_tax', tax.toFixed(2))
|
|
||||||
fd.append('vat_rate', String(rate))
|
|
||||||
if (uploadFile) fd.append('pdf', uploadFile)
|
|
||||||
const res = await fetch(`${base}/api/admin/invoices`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) },
|
|
||||||
body: fd,
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok || body?.success === false) throw new Error(body?.message || `Upload failed (${res.status})`)
|
|
||||||
setUploadModalOpen(false)
|
|
||||||
setUploadForm({
|
|
||||||
buyer_name: '', buyer_email: '', buyer_street: '', buyer_postal_code: '',
|
|
||||||
buyer_city: '', buyer_country: '', currency: 'EUR',
|
|
||||||
total_gross: '', vat_rate: '20',
|
|
||||||
status: 'issued', issued_at: '', due_at: '',
|
|
||||||
})
|
|
||||||
setUploadFile(null)
|
|
||||||
reload()
|
|
||||||
setReportMsg({ type: 'success', text: `Invoice ${body.data?.invoice_number ?? ''} created successfully.` })
|
|
||||||
} catch (e: any) {
|
|
||||||
setUploadError(e?.message || 'Upload failed.')
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendEmailReport = async () => {
|
|
||||||
if (!reportEmail.trim()) return
|
|
||||||
setReportMsg(null)
|
|
||||||
setSendingReport(true)
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
|
|
||||||
const res = await fetch(`${base}/api/admin/invoices/email-report`, {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: reportEmail.trim(),
|
|
||||||
from: billFilter.from || undefined,
|
|
||||||
to: billFilter.to || undefined,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!res.ok || body?.success === false) {
|
|
||||||
throw new Error(body?.message || `Request failed (${res.status})`)
|
|
||||||
}
|
|
||||||
setReportMsg({ type: 'success', text: `Report sent to ${reportEmail.trim()} (${body.data?.sentCount ?? 0} paid invoice(s)).` })
|
|
||||||
setEmailDialogOpen(false)
|
|
||||||
setReportEmail('')
|
|
||||||
} catch (e: any) {
|
|
||||||
setReportMsg({ type: 'error', text: e?.message || 'Failed to send email report.' })
|
|
||||||
} finally {
|
|
||||||
setSendingReport(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
<header className="rounded-2xl bg-white border border-blue-100 shadow-lg px-8 py-8 flex flex-col gap-2">
|
<header className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<h1 className="text-3xl font-extrabold text-blue-900">{t('autofix.k777299de')}</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
<p className="text-sm text-blue-700">{t('autofix.k01ad6d49')}</p>
|
{t('autofix.k8070cd52')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.k777299de')}</h1>
|
||||||
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">{t('autofix.k01ad6d49')}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Stats */}
|
<section className="grid [grid-template-columns:repeat(auto-fit,minmax(16rem,1fr))] gap-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k73f7184d')}</div>
|
||||||
<div className="text-xs text-gray-500 mb-1">Total revenue (all time)</div>
|
<div className="text-2xl font-semibold text-slate-900">EUR {totals.totalAll.toFixed(2)}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalAll.toFixed(2)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Revenue (range)</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k9b3082af')}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">€{totals.totalRange.toFixed(2)}</div>
|
<div className="text-2xl font-semibold text-slate-900">EUR {totals.totalRange.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Invoices (range)</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.k9f4ec5e2')}</div>
|
||||||
<div className="text-2xl font-semibold text-[#1C2B4A]">{filteredBills.length}</div>
|
<div className="text-2xl font-semibold text-slate-900">{filteredBills.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-gray-100 bg-white shadow-lg p-5">
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="text-xs text-gray-500 mb-1">Timeframe</div>
|
<div className="text-xs text-slate-500 mb-1">{t('autofix.kafb65833')}</div>
|
||||||
<select
|
<select
|
||||||
value={timeframe}
|
value={timeframe}
|
||||||
onChange={e => setTimeframe(e.target.value as any)}
|
onChange={(event) => setTimeframe(event.target.value as '7d' | '30d' | '90d' | 'ytd')}
|
||||||
className="mt-2 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="mt-2 w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="7d">{t('autofix.k502a0057')}</option>
|
<option value="7d">{t('autofix.k502a0057')}</option>
|
||||||
<option value="30d">{t('autofix.k5f74c123')}</option>
|
<option value="30d">{t('autofix.k5f74c123')}</option>
|
||||||
<option value="90d">{t('autofix.k915115a9')}</option>
|
<option value="90d">{t('autofix.k915115a9')}</option>
|
||||||
<option value="ytd">YTD</option>
|
<option value="ytd">{t('autofix.k0f5d95a1')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
{/* VAT summary */}
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-3">
|
||||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">{t('autofix.kf2180ff6')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.kf2180ff6')}</h2>
|
||||||
<p className="text-xs text-gray-600">Live data from backend; edit on a separate page.</p>
|
<p className="text-xs text-slate-600">{t('autofix.k5ce7a5b0')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
onClick={() => router.push('/admin/finance-management/vat-edit')}
|
||||||
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90"
|
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 transition"
|
||||||
>{t('autofix.k4191cdba')}</button>
|
>
|
||||||
|
{t('autofix.k4191cdba')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-700">
|
<div className="text-sm text-slate-700">
|
||||||
{vatLoading && 'Loading VAT rates...'}
|
{vatLoading && t('autofix.ka5d50257')}
|
||||||
{vatError && <span className="text-red-600">{vatError}</span>}
|
{vatError && <span className="text-red-600">{vatError}</span>}
|
||||||
{!vatLoading && !vatError && (
|
{!vatLoading && !vatError && (
|
||||||
<>Active countries: {rates.length} • Examples: {rates.slice(0, 5).map(r => r.country_code).join(', ')}</>
|
<>
|
||||||
|
{t('autofix.k3e4a95bc').replace('{count}', String(rates.length)).replace('{examples}', rates.slice(0, 5).map((rate) => rate.country_code).join(', '))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Bills list & filters */}
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
|
||||||
<section className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-semibold text-[#1C2B4A]">Invoices</h2>
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k21f123af')}</h2>
|
||||||
<div className="flex flex-wrap gap-2 text-sm">
|
<div className="flex flex-wrap gap-2 text-sm">
|
||||||
<button onClick={() => { setUploadError(null); setUploadModalOpen(true) }} className="rounded-lg bg-[#1C2B4A] px-3 py-2 text-white font-medium hover:bg-[#1C2B4A]/90">{t('autofix.kec5a5357')}</button>
|
<button
|
||||||
<button onClick={() => { setReportMsg(null); setEmailDialogOpen(true) }} className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-blue-900 font-medium hover:bg-blue-100">{t('autofix.kfdcad59b')}</button>
|
onClick={() => {
|
||||||
<button onClick={() => exportBills('csv')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">{t('autofix.k4c5e8e87')}</button>
|
setUploadError(null)
|
||||||
<button onClick={() => exportBills('pdf')} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">{t('autofix.k4c5ecd73')}</button>
|
setUploadModalOpen(true)
|
||||||
<button onClick={reload} className="rounded-lg border border-gray-200 px-3 py-2 hover:bg-gray-50">Reload</button>
|
}}
|
||||||
|
className="rounded-xl bg-slate-900 px-3 py-2 text-white font-medium hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kec5a5357')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReportMsg(null)
|
||||||
|
setEmailDialogOpen(true)
|
||||||
|
}}
|
||||||
|
className="rounded-xl border border-sky-200 bg-sky-50 px-3 py-2 text-sky-900 font-medium hover:bg-sky-100 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.kfdcad59b')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => exportBills('csv')} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.k4c5e8e87')}</button>
|
||||||
|
<button onClick={() => exportBills('pdf')} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.k4c5ecd73')}</button>
|
||||||
|
<button onClick={reload} className="rounded-xl border border-slate-200 px-3 py-2 hover:bg-slate-50 transition">{t('autofix.kddf7ca98')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4 text-sm">
|
<div className="grid gap-3 lg:grid-cols-4 text-sm">
|
||||||
<input
|
<input
|
||||||
placeholder="Search (invoice no., customer)"
|
placeholder={t('autofix.k8bb2fe26')}
|
||||||
value={billFilter.query}
|
value={billFilter.query}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, query: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, query: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={billFilter.status}
|
value={billFilter.status}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, status: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, status: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="all">Status: All</option>
|
<option value="all">{t('autofix.kec99a6cc')}</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">{t('autofix.k5f6d9f11')}</option>
|
||||||
<option value="issued">Issued</option>
|
<option value="issued">{t('autofix.kdc8f2ab2')}</option>
|
||||||
<option value="paid">Paid</option>
|
<option value="paid">{t('autofix.k9d5b2d74')}</option>
|
||||||
<option value="overdue">Overdue</option>
|
<option value="overdue">{t('autofix.k2f44ec11')}</option>
|
||||||
<option value="canceled">Cancelled</option>
|
<option value="canceled">{t('autofix.kcf31ed66')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={billFilter.from}
|
value={billFilter.from}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, from: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, from: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={billFilter.to}
|
value={billFilter.to}
|
||||||
onChange={e => setBillFilter(f => ({ ...f, to: e.target.value }))}
|
onChange={(event) => setBillFilter((current) => ({ ...current, to: event.target.value }))}
|
||||||
className="rounded-lg border border-gray-200 px-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="rounded-lg border border-slate-200 px-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white/70 p-1">
|
||||||
{reportMsg && (
|
{reportMsg && (
|
||||||
<div className={`rounded-md border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
<div className={`rounded-xl border px-3 py-2 text-sm mb-3 ${reportMsg.type === 'success' ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-700'}`}>
|
||||||
{reportMsg.text}
|
{reportMsg.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{invError && (
|
{invError && (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 mb-3">
|
||||||
{invError}
|
{invError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(diagLoading || diagError || diagData) && (
|
{(diagLoading || diagError || diagData) && (
|
||||||
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
|
<div className="rounded-xl border border-sky-200 bg-sky-50/60 px-3 py-3 text-sm mb-3">
|
||||||
{diagLoading && <div className="text-blue-800">{t('autofix.k37d7b9c4')}</div>}
|
{diagLoading && <div className="text-sky-800">{t('autofix.k37d7b9c4')}</div>}
|
||||||
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
|
||||||
{!diagLoading && !diagError && diagData && (
|
{!diagLoading && !diagError && diagData && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
|
<div className="text-sky-900 font-semibold">
|
||||||
<div className="text-gray-700">{t('autofix.k81c0b74b')}<span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span>{t('autofix.k77049179')}<span className="font-mono">{diagData.reason}</span>
|
{t('autofix.kf6a5a971').replace('{invoice}', String(diagData.invoice_id ?? '—'))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-slate-700">
|
||||||
|
{t('autofix.k81c0b74b')}
|
||||||
|
<span className="font-medium">{diagData.ok ? t('autofix.kaf7e90cc') : t('autofix.k6ba7f5b1')}</span>
|
||||||
|
{t('autofix.k77049179')}
|
||||||
|
<span className="font-mono">{diagData.reason}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{diagData.ok && (
|
{diagData.ok && (
|
||||||
<div className="text-gray-700">{t('autofix.k4968eb2a')}<span className="font-medium">{diagData.abonement_id}</span>{t('autofix.kfaa8fc4a')}<span className="font-medium">{diagData.will_book_count}</span>{t('autofix.kd2e5e813')}<span className="font-medium">{diagData.already_booked_count}</span>
|
<div className="text-slate-700">
|
||||||
|
{t('autofix.k4968eb2a')}
|
||||||
|
<span className="font-medium">{diagData.abonement_id}</span>
|
||||||
|
{t('autofix.kfaa8fc4a')}
|
||||||
|
<span className="font-medium">{diagData.will_book_count}</span>
|
||||||
|
{t('autofix.kd2e5e813')}
|
||||||
|
<span className="font-medium">{diagData.already_booked_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-xs">
|
<table className="min-w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-blue-900">
|
<tr className="text-left text-sky-900">
|
||||||
<th className="pr-3 py-1">Pool</th>
|
<th className="pr-3 py-1">{t('autofix.kf1b73a92')}</th>
|
||||||
<th className="pr-3 py-1">Coffee</th>
|
<th className="pr-3 py-1">{t('autofix.k2f9cd1e0')}</th>
|
||||||
<th className="pr-3 py-1">Capsules</th>
|
<th className="pr-3 py-1">{t('autofix.k1ddc3f42')}</th>
|
||||||
<th className="pr-3 py-1">Amount (gross)</th>
|
<th className="pr-3 py-1">{t('autofix.kdb79aa30')}</th>
|
||||||
<th className="pr-3 py-1">Booked</th>
|
<th className="pr-3 py-1">{t('autofix.k93e61ad1')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{diagData.candidates.map((c: any) => (
|
{diagData.candidates.map((candidate: any) => (
|
||||||
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
|
<tr key={`${candidate.pool_id}-${candidate.coffee_table_id}`}>
|
||||||
<td className="pr-3 py-1">{c.pool_name}</td>
|
<td className="pr-3 py-1">{candidate.pool_name}</td>
|
||||||
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
<td className="pr-3 py-1">#{candidate.coffee_table_id}</td>
|
||||||
<td className="pr-3 py-1">{c.capsules_count}</td>
|
<td className="pr-3 py-1">{candidate.capsules_count}</td>
|
||||||
<td className="pr-3 py-1">€{Number(c.amount_gross ?? c.amount_net ?? 0).toFixed(2)}</td>
|
<td className="pr-3 py-1">EUR {Number(candidate.amount_gross ?? candidate.amount_net ?? 0).toFixed(2)}</td>
|
||||||
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
<td className="pr-3 py-1">{candidate.already_booked ? t('common.yes') : t('common.no')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -399,88 +299,70 @@ export default function FinanceManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
|
<table className="min-w-full text-sm rounded-xl overflow-hidden">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-blue-50 text-left text-blue-900">
|
<tr className="bg-slate-50 text-left text-slate-900">
|
||||||
<th className="px-3 py-2 font-semibold">Invoice</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kf8f0c1f3')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Customer</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kf2b5c1a6')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Issued</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.kd4af6368')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">{t('autofix.k867f8265')}</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k867f8265')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Amount</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k762eef76')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Status</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k81c0b74b')}</th>
|
||||||
<th className="px-3 py-2 font-semibold">Actions</th>
|
<th className="px-3 py-2 font-semibold">{t('autofix.k0afbbac4')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{invLoading ? (
|
{invLoading ? (
|
||||||
<>
|
<>
|
||||||
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-40 bg-slate-200 animate-pulse rounded" /></td></tr>
|
||||||
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" /></td></tr>
|
<tr><td colSpan={7} className="px-3 py-3"><div className="h-4 w-3/4 bg-slate-200 animate-pulse rounded" /></td></tr>
|
||||||
</>
|
</>
|
||||||
) : filteredBills.length === 0 ? (
|
) : filteredBills.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-3 py-4 text-center text-gray-500">{t('autofix.kbdb02e32')}</td>
|
<td colSpan={7} className="px-3 py-4 text-center text-slate-500">{t('autofix.kbdb02e32')}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredBills.map(inv => (
|
filteredBills.map((invoice) => (
|
||||||
<tr key={inv.id} className="border-b last:border-0">
|
<tr key={invoice.id} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-3 py-2">{inv.invoice_number ?? inv.id}</td>
|
<td className="px-3 py-2">{invoice.invoice_number ?? invoice.id}</td>
|
||||||
<td className="px-3 py-2">{inv.buyer_name ?? '—'}</td>
|
<td className="px-3 py-2">{invoice.buyer_name ?? '—'}</td>
|
||||||
<td className="px-3 py-2">{inv.issued_at ? new Date(inv.issued_at).toLocaleDateString() : '—'}</td>
|
<td className="px-3 py-2">{invoice.issued_at ? new Date(invoice.issued_at).toLocaleDateString() : '—'}</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!inv.due_at) return <span className="text-gray-400">—</span>
|
if (!invoice.due_at) return <span className="text-slate-400">—</span>
|
||||||
const due = new Date(inv.due_at)
|
|
||||||
|
const due = new Date(invoice.due_at)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
const diffDays = Math.ceil((due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
let cls = 'bg-green-100 text-green-700' // plenty of time
|
|
||||||
if (inv.status === 'paid') cls = 'bg-green-100 text-green-700'
|
let cls = 'bg-green-100 text-green-700'
|
||||||
|
if (invoice.status === 'paid') cls = 'bg-green-100 text-green-700'
|
||||||
else if (diffDays < 0) cls = 'bg-red-100 text-red-700'
|
else if (diffDays < 0) cls = 'bg-red-100 text-red-700'
|
||||||
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
|
else if (diffDays <= 3) cls = 'bg-red-100 text-red-700'
|
||||||
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
|
else if (diffDays <= 7) cls = 'bg-amber-100 text-amber-700'
|
||||||
return (
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>
|
return <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${cls}`}>{due.toLocaleDateString()}</span>
|
||||||
{due.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
€{Number(inv.total_gross ?? 0).toFixed(2)}{' '}
|
EUR {Number(invoice.total_gross ?? 0).toFixed(2)} <span className="text-xs text-slate-500">{invoice.currency ?? 'EUR'}</span>
|
||||||
<span className="text-xs text-gray-500">{inv.currency ?? 'EUR'}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${getStatusBadgeClass(invoice.status ?? '')}`}>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-semibold ${
|
{getStatusLabel(t, invoice.status ?? '')}
|
||||||
inv.status === 'paid'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: inv.status === 'issued'
|
|
||||||
? 'bg-indigo-100 text-indigo-700'
|
|
||||||
: inv.status === 'draft'
|
|
||||||
? 'bg-gray-100 text-gray-700'
|
|
||||||
: inv.status === 'overdue'
|
|
||||||
? 'bg-red-100 text-red-700'
|
|
||||||
: 'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{inv.status}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 space-x-2">
|
<FinanceInvoiceActions
|
||||||
<button
|
invoice={invoice}
|
||||||
onClick={() => viewInvoicePdf(inv)}
|
pdfLoading={pdfLoading}
|
||||||
disabled={pdfLoading === inv.id || !inv.pdf_storage_key}
|
onViewPdf={viewInvoicePdf}
|
||||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
onOpenDetails={(value) => {
|
||||||
>
|
setSelectedInvoice(value)
|
||||||
{pdfLoading === inv.id ? 'Loading…' : 'View PDF'}
|
setDetailModalOpen(true)
|
||||||
</button>
|
}}
|
||||||
<button
|
t={t}
|
||||||
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
|
/>
|
||||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -492,152 +374,145 @@ export default function FinanceManagementPage() {
|
|||||||
<InvoiceDetailModal
|
<InvoiceDetailModal
|
||||||
invoice={selectedInvoice}
|
invoice={selectedInvoice}
|
||||||
open={detailModalOpen}
|
open={detailModalOpen}
|
||||||
onClose={() => { setDetailModalOpen(false); setTimeout(() => setSelectedInvoice(null), 200) }}
|
onClose={() => {
|
||||||
|
setDetailModalOpen(false)
|
||||||
|
setTimeout(() => setSelectedInvoice(null), 200)
|
||||||
|
}}
|
||||||
onStatusChanged={reload}
|
onStatusChanged={reload}
|
||||||
onRunPoolCheck={(id) => { setDetailModalOpen(false); runPoolCheck(id) }}
|
onRunPoolCheck={(id) => {
|
||||||
onExport={(inv) => exportInvoice(inv)}
|
setDetailModalOpen(false)
|
||||||
|
runPoolCheck(id)
|
||||||
|
}}
|
||||||
|
onExport={(invoice) => exportInvoice(invoice)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Upload Invoice Modal */}
|
|
||||||
{uploadModalOpen && (
|
{uploadModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/35 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl overflow-y-auto max-h-[90vh]">
|
<div className="w-full max-w-2xl rounded-[28px] border border-white/80 bg-white/95 p-6 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.35)] overflow-y-auto max-h-[90vh]">
|
||||||
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-4">{t('autofix.kec5a5357')}</h3>
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">{t('autofix.kec5a5357')}</h3>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kf2b5c1a6')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kf2b5c1a6')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_name} onChange={e => setUploadForm(f => ({ ...f, buyer_name: e.target.value }))} placeholder={t('autofix.k1882bd75')} />
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_name} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_name: event.target.value }))} placeholder={t('autofix.k1882bd75')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.k48852b8d')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k48852b8d')}</label>
|
||||||
<input type="email" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_email} onChange={e => setUploadForm(f => ({ ...f, buyer_email: e.target.value }))} placeholder={t('autofix.kf8c220d3')} />
|
<input type="email" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_email} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_email: event.target.value }))} placeholder={t('autofix.kf8c220d3')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Street</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kba8ee9b1')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_street} onChange={e => setUploadForm(f => ({ ...f, buyer_street: e.target.value }))} placeholder={t('autofix.k81c7c2f2')} />
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_street} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_street: event.target.value }))} placeholder={t('autofix.k81c7c2f2')} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kc9d9d15d')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={e => setUploadForm(f => ({ ...f, buyer_postal_code: e.target.value }))} placeholder="8010" />
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_postal_code} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_postal_code: event.target.value }))} placeholder="8010" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">City</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k5d52917f')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_city} onChange={e => setUploadForm(f => ({ ...f, buyer_city: e.target.value }))} placeholder="Graz" />
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_city} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_city: event.target.value }))} placeholder="Graz" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Country</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k9e39e560')}</label>
|
||||||
<input className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.buyer_country} onChange={e => setUploadForm(f => ({ ...f, buyer_country: e.target.value }))} placeholder="Austria" />
|
<input className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.buyer_country} onChange={(event) => setUploadForm((current) => ({ ...current, buyer_country: event.target.value }))} placeholder="Austria" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.k002455d8')}<span className="text-red-500">*</span></label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k002455d8')}<span className="text-red-500">*</span></label>
|
||||||
<input type="number" step="0.01" min="0" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.total_gross} onChange={e => setUploadForm(f => ({ ...f, total_gross: e.target.value }))} placeholder="0.00" />
|
<input type="number" step="0.01" min="0" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.total_gross} onChange={(event) => setUploadForm((current) => ({ ...current, total_gross: event.target.value }))} placeholder="0.00" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">MwSt. / VAT Rate (%)</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k57d5f250')}</label>
|
||||||
<input type="number" step="0.01" min="0" max="100" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.vat_rate} onChange={e => setUploadForm(f => ({ ...f, vat_rate: e.target.value }))} placeholder="20" />
|
<input type="number" step="0.01" min="0" max="100" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.vat_rate} onChange={(event) => setUploadForm((current) => ({ ...current, vat_rate: event.target.value }))} placeholder="20" />
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
|
||||||
const gross = parseFloat(uploadForm.total_gross) || 0
|
|
||||||
const rate = parseFloat(uploadForm.vat_rate) || 0
|
|
||||||
const net = rate > 0 ? +(gross / (1 + rate / 100)).toFixed(2) : gross
|
|
||||||
const tax = +(gross - net).toFixed(2)
|
|
||||||
return (
|
|
||||||
<div className="sm:col-span-2 grid grid-cols-2 gap-3">
|
<div className="sm:col-span-2 grid grid-cols-2 gap-3">
|
||||||
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2">
|
<div className="rounded-lg bg-slate-50 border border-slate-100 px-3 py-2">
|
||||||
<div className="text-xs text-gray-500 mb-0.5">Netto (calculated)</div>
|
<div className="text-xs text-slate-500 mb-0.5">{t('autofix.k1f5a403a')}</div>
|
||||||
<div className="font-semibold text-gray-800">{uploadForm.currency} {net.toFixed(2)}</div>
|
<div className="font-semibold text-slate-800">{uploadForm.currency} {uploadPreview.net.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2">
|
<div className="rounded-lg bg-slate-50 border border-slate-100 px-3 py-2">
|
||||||
<div className="text-xs text-gray-500 mb-0.5">MwSt. (calculated)</div>
|
<div className="text-xs text-slate-500 mb-0.5">{t('autofix.k089e8c08')}</div>
|
||||||
<div className="font-semibold text-gray-800">{uploadForm.currency} {tax.toFixed(2)}</div>
|
<div className="font-semibold text-slate-800">{uploadForm.currency} {uploadPreview.tax.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
})()}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Currency</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k3466b0e0')}</label>
|
||||||
<select className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.currency} onChange={e => setUploadForm(f => ({ ...f, currency: e.target.value }))}>
|
<select className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.currency} onChange={(event) => setUploadForm((current) => ({ ...current, currency: event.target.value }))}>
|
||||||
<option value="EUR">EUR</option>
|
<option value="EUR">EUR</option>
|
||||||
<option value="CHF">CHF</option>
|
<option value="CHF">CHF</option>
|
||||||
<option value="USD">USD</option>
|
<option value="USD">USD</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k81c0b74b')}</label>
|
||||||
<select className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.status} onChange={e => setUploadForm(f => ({ ...f, status: e.target.value }))}>
|
<select className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.status} onChange={(event) => setUploadForm((current) => ({ ...current, status: event.target.value }))}>
|
||||||
<option value="issued">Issued</option>
|
<option value="issued">{t('autofix.kdc8f2ab2')}</option>
|
||||||
<option value="paid">Paid</option>
|
<option value="paid">{t('autofix.k9d5b2d74')}</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">{t('autofix.k5f6d9f11')}</option>
|
||||||
<option value="overdue">Overdue</option>
|
<option value="overdue">{t('autofix.k2f44ec11')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kd4af6368')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kd4af6368')}</label>
|
||||||
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.issued_at} onChange={e => setUploadForm(f => ({ ...f, issued_at: e.target.value }))} />
|
<input type="date" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.issued_at} onChange={(event) => setUploadForm((current) => ({ ...current, issued_at: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.k867f8265')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.k867f8265')}</label>
|
||||||
<input type="date" className="w-full rounded-lg border border-gray-200 px-3 py-2" value={uploadForm.due_at} onChange={e => setUploadForm(f => ({ ...f, due_at: e.target.value }))} />
|
<input type="date" className="w-full rounded-lg border border-slate-200 px-3 py-2" value={uploadForm.due_at} onChange={(event) => setUploadForm((current) => ({ ...current, due_at: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2">
|
<div className="sm:col-span-2">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('autofix.kd6024811')}</label>
|
<label className="block text-xs font-medium text-slate-700 mb-1">{t('autofix.kd6024811')}</label>
|
||||||
<input
|
<input
|
||||||
type="file" accept="application/pdf"
|
type="file"
|
||||||
className="w-full text-sm text-gray-700 file:mr-3 file:rounded-lg file:border-0 file:bg-blue-50 file:px-3 file:py-2 file:text-blue-900 file:font-medium hover:file:bg-blue-100"
|
accept="application/pdf"
|
||||||
onChange={e => setUploadFile(e.target.files?.[0] ?? null)}
|
className="w-full text-sm text-slate-700 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-50 file:px-3 file:py-2 file:text-sky-900 file:font-medium hover:file:bg-sky-100"
|
||||||
|
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
{uploadFile && <p className="mt-1 text-xs text-gray-500">{uploadFile.name}</p>}
|
{uploadFile && <p className="mt-1 text-xs text-slate-500">{uploadFile.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploadError && (
|
|
||||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{uploadError}</div>
|
{uploadError && <div className="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{uploadError}</div>}
|
||||||
)}
|
|
||||||
<div className="mt-5 flex items-center justify-end gap-2">
|
<div className="mt-5 flex items-center justify-end gap-2">
|
||||||
<button onClick={() => { setUploadModalOpen(false); setUploadError(null) }} disabled={uploading} className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60">Cancel</button>
|
<button onClick={() => { setUploadModalOpen(false); setUploadError(null) }} disabled={uploading} className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-60">{t('common.cancel')}</button>
|
||||||
<button onClick={submitUploadInvoice} disabled={uploading} className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90 disabled:opacity-60 disabled:cursor-not-allowed">
|
<button onClick={submitUploadInvoice} disabled={uploading} className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
{uploading ? 'Uploading…' : 'Create Invoice'}
|
{uploading ? t('autofix.k3bc9a0f1') : t('autofix.k1139753d')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Report Dialog */}
|
|
||||||
{emailDialogOpen && (
|
{emailDialogOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/35 backdrop-blur-sm">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/95 p-6 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.35)]">
|
||||||
<h3 className="text-lg font-semibold text-[#1C2B4A] mb-1">{t('autofix.kfdcad59b')}</h3>
|
<h3 className="text-lg font-semibold text-slate-900 mb-1">{t('autofix.kfdcad59b')}</h3>
|
||||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
|
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">
|
||||||
Only <strong>paid</strong> invoices will be included in the report, regardless of the status filter.
|
{t('autofix.k45c3fd51').replace('{paid}', t('autofix.k9d5b2d74').toLowerCase())}
|
||||||
{(billFilter.from || billFilter.to) && (
|
{(billFilter.from || billFilter.to) && (
|
||||||
<span> The current date range filter ({billFilter.from || '…'} – {billFilter.to || '…'}) will be applied.</span>
|
<span> {t('autofix.kdd22a5f2').replace('{from}', billFilter.from || '…').replace('{to}', billFilter.to || '…')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kd56a13f2')}</label>
|
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">{t('autofix.kd56a13f2')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={reportEmail}
|
value={reportEmail}
|
||||||
onChange={e => setReportEmail(e.target.value)}
|
onChange={(event) => setReportEmail(event.target.value)}
|
||||||
placeholder={t('autofix.k51ee3aae')}
|
placeholder={t('autofix.k51ee3aae')}
|
||||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
autoFocus
|
autoFocus
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !sendingReport) sendEmailReport() }}
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !sendingReport) sendEmailReport()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<button
|
<button onClick={() => { setEmailDialogOpen(false); setReportEmail('') }} disabled={sendingReport} className="rounded-xl border border-slate-200 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-60">{t('common.cancel')}</button>
|
||||||
onClick={() => { setEmailDialogOpen(false); setReportEmail('') }}
|
<button onClick={sendEmailReport} disabled={sendingReport || !reportEmail.trim()} className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
disabled={sendingReport}
|
{sendingReport ? t('autofix.k795911e8') : t('autofix.kf6f9b3c0')}
|
||||||
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={sendEmailReport}
|
|
||||||
disabled={sendingReport || !reportEmail.trim()}
|
|
||||||
className="rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white shadow hover:bg-[#1C2B4A]/90 disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{sendingReport ? 'Sending…' : 'Send Report'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,6 +30,7 @@ type Props = {
|
|||||||
scanError: string | null;
|
scanError: string | null;
|
||||||
isScanning: boolean;
|
isScanning: boolean;
|
||||||
isAutoFixing: boolean;
|
isAutoFixing: boolean;
|
||||||
|
isAddingMissingKeys: boolean;
|
||||||
fixableFiles: string[];
|
fixableFiles: string[];
|
||||||
selectedFiles: string[];
|
selectedFiles: string[];
|
||||||
forceConvertToClient: boolean;
|
forceConvertToClient: boolean;
|
||||||
@ -38,6 +39,7 @@ type Props = {
|
|||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onToggleForceConvertToClient: () => void;
|
onToggleForceConvertToClient: () => void;
|
||||||
onRunFixSelected: () => void;
|
onRunFixSelected: () => void;
|
||||||
|
onAddMissingKeys: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScanResultsModal({
|
export default function ScanResultsModal({
|
||||||
@ -53,6 +55,7 @@ export default function ScanResultsModal({
|
|||||||
scanError,
|
scanError,
|
||||||
isScanning,
|
isScanning,
|
||||||
isAutoFixing,
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
fixableFiles,
|
fixableFiles,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
forceConvertToClient,
|
forceConvertToClient,
|
||||||
@ -61,6 +64,7 @@ export default function ScanResultsModal({
|
|||||||
onClear,
|
onClear,
|
||||||
onToggleForceConvertToClient,
|
onToggleForceConvertToClient,
|
||||||
onRunFixSelected,
|
onRunFixSelected,
|
||||||
|
onAddMissingKeys,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
const { isRendered, isVisible } = useModalAnimation(isOpen);
|
||||||
@ -165,6 +169,10 @@ export default function ScanResultsModal({
|
|||||||
<div className="mb-4 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-700">{t('autofix.ka802064d')}</div>
|
<div className="mb-4 rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-700">{t('autofix.ka802064d')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isAddingMissingKeys && (
|
||||||
|
<div className="mb-4 rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700">{t('autofix.k68e73120')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5 items-start">
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-5 items-start">
|
||||||
<div className="xl:col-span-5 space-y-4">
|
<div className="xl:col-span-5 space-y-4">
|
||||||
<ScanFixPanel
|
<ScanFixPanel
|
||||||
@ -293,7 +301,15 @@ export default function ScanResultsModal({
|
|||||||
|
|
||||||
{workspaceScan && workspaceScan.missingKeys.length > 0 && (
|
{workspaceScan && workspaceScan.missingKeys.length > 0 && (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50/50 p-4">
|
<div className="rounded-xl border border-red-200 bg-red-50/50 p-4">
|
||||||
<h3 className="text-sm font-semibold text-red-700 mb-2">{t('autofix.kae63e46a')}</h3>
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold text-red-700">{t('autofix.kae63e46a')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddMissingKeys}
|
||||||
|
disabled={isAddingMissingKeys || isAutoFixing || isScanning}
|
||||||
|
className="rounded-md bg-red-700 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>{t('autofix.kbd3f0f44')}</button>
|
||||||
|
</div>
|
||||||
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
|
||||||
{workspaceScan.missingKeys.map((entry) => (
|
{workspaceScan.missingKeys.map((entry) => (
|
||||||
<div key={entry.key} className="rounded-md border border-red-100 bg-white px-3 py-2">
|
<div key={entry.key} className="rounded-md border border-red-100 bg-white px-3 py-2">
|
||||||
|
|||||||
@ -116,6 +116,7 @@ export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptio
|
|||||||
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
const [lastScanTime, setLastScanTime] = useState<Date | null>(null)
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
const [isAutoFixing, setIsAutoFixing] = useState(false)
|
const [isAutoFixing, setIsAutoFixing] = useState(false)
|
||||||
|
const [isAddingMissingKeys, setIsAddingMissingKeys] = useState(false)
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
const [workspaceScan, setWorkspaceScan] = useState<WorkspaceScanResult | null>(null)
|
const [workspaceScan, setWorkspaceScan] = useState<WorkspaceScanResult | null>(null)
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
@ -204,6 +205,35 @@ export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMissingKeys = async () => {
|
||||||
|
setShowScanModal(true)
|
||||||
|
setIsAddingMissingKeys(true)
|
||||||
|
setScanError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/api/i18n/scan', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mode: 'add-missing-keys' }),
|
||||||
|
})
|
||||||
|
if (response.status === 401) {
|
||||||
|
onUnauthorizedRef.current?.()
|
||||||
|
throw new Error('Session expired. Redirecting to login.')
|
||||||
|
}
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
throw new Error(result?.message || 'Adding missing keys failed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
applyScanResult(result)
|
||||||
|
} catch (error) {
|
||||||
|
setScanError(error instanceof Error ? error.message : 'Adding missing keys failed.')
|
||||||
|
} finally {
|
||||||
|
setIsAddingMissingKeys(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleFileSelection = (file: string) => {
|
const toggleFileSelection = (file: string) => {
|
||||||
setSelectedFiles((prev) => {
|
setSelectedFiles((prev) => {
|
||||||
if (prev.includes(file)) return prev.filter((f) => f !== file)
|
if (prev.includes(file)) return prev.filter((f) => f !== file)
|
||||||
@ -225,6 +255,7 @@ export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptio
|
|||||||
lastScanTime,
|
lastScanTime,
|
||||||
isScanning,
|
isScanning,
|
||||||
isAutoFixing,
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
scanError,
|
scanError,
|
||||||
workspaceScan,
|
workspaceScan,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@ -233,6 +264,7 @@ export function useI18nScanWorkflow({ onUnauthorized }: UseI18nScanWorkflowOptio
|
|||||||
setForceConvertToClient,
|
setForceConvertToClient,
|
||||||
scan,
|
scan,
|
||||||
runFixSelected,
|
runFixSelected,
|
||||||
|
addMissingKeys,
|
||||||
toggleFileSelection,
|
toggleFileSelection,
|
||||||
selectAllFiles,
|
selectAllFiles,
|
||||||
clearSelectedFiles,
|
clearSelectedFiles,
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export default function LanguageManagementPage() {
|
|||||||
lastScanTime,
|
lastScanTime,
|
||||||
isScanning,
|
isScanning,
|
||||||
isAutoFixing,
|
isAutoFixing,
|
||||||
|
isAddingMissingKeys,
|
||||||
scanError,
|
scanError,
|
||||||
workspaceScan,
|
workspaceScan,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
@ -77,6 +78,7 @@ export default function LanguageManagementPage() {
|
|||||||
setForceConvertToClient,
|
setForceConvertToClient,
|
||||||
scan,
|
scan,
|
||||||
runFixSelected,
|
runFixSelected,
|
||||||
|
addMissingKeys,
|
||||||
toggleFileSelection,
|
toggleFileSelection,
|
||||||
selectAllFiles,
|
selectAllFiles,
|
||||||
clearSelectedFiles,
|
clearSelectedFiles,
|
||||||
@ -651,7 +653,7 @@ export default function LanguageManagementPage() {
|
|||||||
return (
|
return (
|
||||||
<PageLayout contentClassName="flex-1 relative w-full">
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<div className="mx-auto max-w-7xl px-4 py-8 space-y-5 sm:px-6 lg:px-8">
|
<div className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
{showFetchingScreen ? (
|
{showFetchingScreen ? (
|
||||||
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@ -858,7 +860,7 @@ export default function LanguageManagementPage() {
|
|||||||
isSaveBarVisible ? 'opacity-100' : 'opacity-0'
|
isSaveBarVisible ? 'opacity-100' : 'opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-7xl flex items-center justify-between gap-4">
|
<div className="max-w-[1820px] mx-auto flex items-center justify-between gap-4">
|
||||||
<span className="text-sm font-medium text-slate-700">{t('autofix.kd63c8219')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('autofix.kd63c8219')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveAll}
|
onClick={handleSaveAll}
|
||||||
@ -949,6 +951,7 @@ export default function LanguageManagementPage() {
|
|||||||
scanError={scanError}
|
scanError={scanError}
|
||||||
isScanning={isScanning}
|
isScanning={isScanning}
|
||||||
isAutoFixing={isAutoFixing}
|
isAutoFixing={isAutoFixing}
|
||||||
|
isAddingMissingKeys={isAddingMissingKeys}
|
||||||
fixableFiles={fixableFiles}
|
fixableFiles={fixableFiles}
|
||||||
selectedFiles={selectedFiles}
|
selectedFiles={selectedFiles}
|
||||||
forceConvertToClient={forceConvertToClient}
|
forceConvertToClient={forceConvertToClient}
|
||||||
@ -957,6 +960,7 @@ export default function LanguageManagementPage() {
|
|||||||
onClear={clearSelectedFiles}
|
onClear={clearSelectedFiles}
|
||||||
onToggleForceConvertToClient={() => setForceConvertToClient((prev) => !prev)}
|
onToggleForceConvertToClient={() => setForceConvertToClient((prev) => !prev)}
|
||||||
onRunFixSelected={handleRunFixSelected}
|
onRunFixSelected={handleRunFixSelected}
|
||||||
|
onAddMissingKeys={addMissingKeys}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
160
src/app/admin/pool-management/components/PoolManagementGrid.tsx
Normal file
160
src/app/admin/pool-management/components/PoolManagementGrid.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { AdminPool } from '../hooks/getlist'
|
||||||
|
import { translateMaybeKey } from '../utils/translateMaybeKey'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
pools: AdminPool[]
|
||||||
|
filteredPools: AdminPool[]
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
archiveError: string
|
||||||
|
showInactive: boolean
|
||||||
|
onManage: (pool: AdminPool) => void
|
||||||
|
onArchive: (poolId: string) => void
|
||||||
|
onActivate: (poolId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManagementGrid({
|
||||||
|
t,
|
||||||
|
pools,
|
||||||
|
filteredPools,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
archiveError,
|
||||||
|
showInactive,
|
||||||
|
onManage,
|
||||||
|
onArchive,
|
||||||
|
onActivate,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-6 sm:p-7 backdrop-blur-md">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k5857ef79')}</h2>
|
||||||
|
<span className="text-sm text-slate-600">{t('autofix.k5f4d2c11').replace('{count}', String(pools.length))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{archiveError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{archiveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))] gap-6">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5">
|
||||||
|
<div className="animate-pulse space-y-3">
|
||||||
|
<div className="h-5 w-1/2 bg-slate-200 rounded" />
|
||||||
|
<div className="h-4 w-3/4 bg-slate-200 rounded" />
|
||||||
|
<div className="h-4 w-2/3 bg-slate-100 rounded" />
|
||||||
|
<div className="h-8 w-full bg-slate-100 rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(19rem,1fr))] gap-6">
|
||||||
|
{filteredPools.map((pool) => {
|
||||||
|
const isCore = pool.pool_name === 'Core'
|
||||||
|
const poolDescription = translateMaybeKey(t, pool.description, '-')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={pool.id}
|
||||||
|
className={`rounded-2xl border shadow-sm p-5 flex flex-col ${
|
||||||
|
isCore
|
||||||
|
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
||||||
|
: 'bg-white border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCore && (
|
||||||
|
<div className="self-start inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm mb-3">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||||
|
{t('autofix.k87e4b9a2')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center shrink-0 ${
|
||||||
|
isCore ? 'bg-amber-100 border-amber-300' : 'bg-slate-50 border-slate-200'
|
||||||
|
}`}>
|
||||||
|
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-slate-900'}`} />
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-lg font-semibold break-words ${isCore ? 'text-amber-900' : 'text-slate-900'}`}>
|
||||||
|
{pool.pool_name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||||
|
!pool.is_active ? 'bg-slate-100 text-slate-700' : 'bg-green-100 text-green-800'
|
||||||
|
}`}>
|
||||||
|
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-slate-400' : 'bg-green-500'}`} />
|
||||||
|
{!pool.is_active ? t('autofix.ke2a1b003') : t('autofix.k3bc84f12')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-slate-700 break-words">{poolDescription}</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-slate-600">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">{t('autofix.kfd227aa9')}</span>
|
||||||
|
<div className="font-medium text-slate-900">{pool.membersCount}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">{t('autofix.k91c8d444')}</span>
|
||||||
|
<div className="font-medium text-slate-900 break-words">
|
||||||
|
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-2">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-slate-200 text-slate-700 hover:bg-slate-300 transition"
|
||||||
|
onClick={() => onManage(pool)}
|
||||||
|
>
|
||||||
|
{t('autofix.k7d2a1190')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!pool.is_active ? (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-green-100 text-green-800 hover:bg-green-200 transition"
|
||||||
|
onClick={() => onActivate(pool.id)}
|
||||||
|
title={t('autofix.kd40c4f86')}
|
||||||
|
>
|
||||||
|
{t('autofix.ke697b8cb')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
||||||
|
onClick={() => onArchive(pool.id)}
|
||||||
|
title={t('autofix.ke19afb3d')}
|
||||||
|
>
|
||||||
|
{t('autofix.kf3b0c221')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{filteredPools.length === 0 && !loading && !error && (
|
||||||
|
<div className="col-span-full text-center text-slate-500 italic py-6">
|
||||||
|
{showInactive ? t('autofix.k1e2f3a44') : t('autofix.ka8b3c104')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
showInactive: boolean
|
||||||
|
onShowActive: () => void
|
||||||
|
onShowInactive: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManagementHeader({ t, showInactive, onShowActive, onShowInactive }: Props) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/85 py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-8">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k6f7f26a1')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.k21440f8a')}</h1>
|
||||||
|
<p className="text-base sm:text-lg text-slate-600 mt-2 break-words">{t('autofix.k67391c88')}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-600">{t('autofix.k0dd01c1c')}</span>
|
||||||
|
<button
|
||||||
|
onClick={onShowActive}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-xl transition ${!showInactive ? 'bg-slate-900 text-white' : 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{t('autofix.k15843a06')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onShowInactive}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-xl transition ${showInactive ? 'bg-slate-900 text-white' : 'bg-white text-slate-700 border border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{t('autofix.kb5e0b861')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -60,7 +60,7 @@ export default function CreateNewPoolModal({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { clearMessages(); onClose(); }}
|
onClick={() => { clearMessages(); onClose(); }}
|
||||||
className="text-gray-500 hover:text-gray-700 transition text-sm"
|
className="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||||
aria-label="Close"
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@ -103,7 +103,7 @@ export default function CreateNewPoolModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k40b5c1d2')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||||
rows={3}
|
rows={3}
|
||||||
@ -114,7 +114,7 @@ export default function CreateNewPoolModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-blue-900 mb-1">Price per capsule (net)</label>
|
<label className="block text-sm font-medium text-blue-900 mb-1">{t('autofix.k8ef02c19')}</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@ -136,8 +136,8 @@ export default function CreateNewPoolModal({
|
|||||||
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
|
onChange={e => setPoolType(e.target.value as 'coffee' | 'other')}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<option value="other">Other</option>
|
<option value="other">{t('autofix.ka320df81')}</option>
|
||||||
<option value="coffee">Coffee</option>
|
<option value="coffee">{t('autofix.k2f9cd1e0')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -148,7 +148,7 @@ export default function CreateNewPoolModal({
|
|||||||
onChange={e => setSubscriptionCoffeeId(e.target.value)}
|
onChange={e => setSubscriptionCoffeeId(e.target.value)}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
<option value="">No subscription selected (set later)</option>
|
<option value="">{t('autofix.kb8d70f41')}</option>
|
||||||
{subscriptions.map((s) => (
|
{subscriptions.map((s) => (
|
||||||
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||||
))}
|
))}
|
||||||
@ -161,7 +161,7 @@ export default function CreateNewPoolModal({
|
|||||||
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
|
||||||
{creating ? 'Creating...' : 'Create Pool'}
|
{creating ? t('autofix.k241a2d77') : t('autofix.kf9d2e4a0')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -169,7 +169,7 @@ export default function CreateNewPoolModal({
|
|||||||
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
|
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
>
|
>
|
||||||
Reset
|
{t('autofix.k612fc0a4')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { authFetch } from '../../../utils/authFetch';
|
import { authFetch } from '../../../utils/authFetch';
|
||||||
import { log } from '../../../utils/logger';
|
import { log } from '../../../utils/logger';
|
||||||
|
import { resolvePoolDescriptionKey } from '../utils/poolDescriptionKey';
|
||||||
|
|
||||||
export type AdminPool = {
|
export type AdminPool = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,7 +64,11 @@ export function useAdminPools() {
|
|||||||
const mapped: AdminPool[] = apiItems.map(item => ({
|
const mapped: AdminPool[] = apiItems.map(item => ({
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
description: resolvePoolDescriptionKey(
|
||||||
|
String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
|
item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
|
String(item.description ?? '')
|
||||||
|
),
|
||||||
price: Number(item.price_net ?? item.price ?? 0),
|
price: Number(item.price_net ?? item.price ?? 0),
|
||||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||||
subscription_title: item.subscription_title ?? null,
|
subscription_title: item.subscription_title ?? null,
|
||||||
@ -103,7 +108,11 @@ export function useAdminPools() {
|
|||||||
setPools(apiItems.map(item => ({
|
setPools(apiItems.map(item => ({
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
description: String(item.description ?? ''),
|
description: resolvePoolDescriptionKey(
|
||||||
|
String(item.pool_name ?? 'Unnamed Pool'),
|
||||||
|
item.pool_type === 'coffee' ? 'coffee' : 'other',
|
||||||
|
String(item.description ?? '')
|
||||||
|
),
|
||||||
price: Number(item.price_net ?? item.price ?? 0),
|
price: Number(item.price_net ?? item.price ?? 0),
|
||||||
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
|
||||||
subscription_title: item.subscription_title ?? null,
|
subscription_title: item.subscription_title ?? null,
|
||||||
|
|||||||
112
src/app/admin/pool-management/hooks/usePoolManagementPage.ts
Normal file
112
src/app/admin/pool-management/hooks/usePoolManagementPage.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { setPoolActive, setPoolInactive } from './poolStatus'
|
||||||
|
import type { AdminPool } from './getlist'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export type PoolAction = 'archive' | 'activate'
|
||||||
|
|
||||||
|
export function usePoolManagementPage({
|
||||||
|
initialPools,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refresh,
|
||||||
|
}: {
|
||||||
|
initialPools: AdminPool[]
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
refresh?: () => Promise<boolean>
|
||||||
|
}) {
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user &&
|
||||||
|
((user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
(user as any)?.roles?.includes?.('admin'))
|
||||||
|
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
const [archiveError, setArchiveError] = useState('')
|
||||||
|
const [poolStatusConfirm, setPoolStatusConfirm] = useState<{ poolId: string; action: PoolAction } | null>(null)
|
||||||
|
const [poolStatusPending, setPoolStatusPending] = useState(false)
|
||||||
|
const [pools, setPools] = useState<AdminPool[]>([])
|
||||||
|
const [showInactive, setShowInactive] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !error) {
|
||||||
|
setPools(initialPools)
|
||||||
|
}
|
||||||
|
}, [initialPools, loading, error])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
const filteredPools = useMemo(
|
||||||
|
() => pools.filter((pool) => (showInactive ? !pool.is_active : pool.is_active)),
|
||||||
|
[pools, showInactive]
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestArchive = (poolId: string) => {
|
||||||
|
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestActivate = (poolId: string) => {
|
||||||
|
setPoolStatusConfirm({ poolId, action: 'activate' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePoolStatusConfirm = () => {
|
||||||
|
if (!poolStatusPending) {
|
||||||
|
setPoolStatusConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPoolStatusChange = async () => {
|
||||||
|
if (!poolStatusConfirm) return
|
||||||
|
|
||||||
|
const { poolId, action } = poolStatusConfirm
|
||||||
|
setPoolStatusPending(true)
|
||||||
|
setArchiveError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await refresh?.()
|
||||||
|
} else {
|
||||||
|
setArchiveError(response.message || (action === 'archive' ? t('autofix.k1a0d4f73') : t('autofix.k54a977c3')))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPoolStatusPending(false)
|
||||||
|
setPoolStatusConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
authChecked,
|
||||||
|
archiveError,
|
||||||
|
poolStatusConfirm,
|
||||||
|
poolStatusPending,
|
||||||
|
pools,
|
||||||
|
showInactive,
|
||||||
|
setShowInactive,
|
||||||
|
filteredPools,
|
||||||
|
requestArchive,
|
||||||
|
requestActivate,
|
||||||
|
closePoolStatusConfirm,
|
||||||
|
confirmPoolStatusChange,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { translateMaybeKey } from '../../utils/translateMaybeKey'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
poolId: string
|
||||||
|
poolName: string
|
||||||
|
poolDescription: string
|
||||||
|
poolPrice: number
|
||||||
|
poolIsActive: boolean
|
||||||
|
poolCreatedAt: string
|
||||||
|
isCore: boolean
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManageHeader({
|
||||||
|
t,
|
||||||
|
poolId,
|
||||||
|
poolName,
|
||||||
|
poolDescription,
|
||||||
|
poolPrice,
|
||||||
|
poolIsActive,
|
||||||
|
poolCreatedAt,
|
||||||
|
isCore,
|
||||||
|
onBack,
|
||||||
|
}: Props) {
|
||||||
|
const resolvedDescription = translateMaybeKey(t, poolDescription, t('autofix.kf0c9a38d'))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={`rounded-[28px] border py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-8 ${
|
||||||
|
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/85 border-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCore && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-3">
|
||||||
|
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||||
|
{t('autofix.k39437388')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={`h-10 w-10 rounded-lg border flex items-center justify-center shrink-0 ${
|
||||||
|
isCore ? 'bg-amber-100 border-amber-300' : 'bg-slate-50 border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-slate-900'}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className={`text-3xl font-extrabold tracking-tight break-words ${isCore ? 'text-amber-900' : 'text-slate-900'}`}>{poolName}</h1>
|
||||||
|
<p className={`text-sm mt-1 break-words ${isCore ? 'text-amber-700' : 'text-slate-600'}`}>
|
||||||
|
{resolvedDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-slate-600">
|
||||||
|
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-slate-100 text-slate-700' : 'bg-green-100 text-green-800'}`}>
|
||||||
|
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-slate-400' : 'bg-green-500'}`} />
|
||||||
|
{!poolIsActive ? t('autofix.ke2a1b003') : t('autofix.k3bc84f12')}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="break-words">{t('autofix.k0a7d2d1e')} EUR {Number(poolPrice || 0).toFixed(2)}{isCore ? ` ${t('autofix.k9fb4721a')}` : ''}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{t('autofix.k91c8d444')} {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-slate-500 break-all">{t('autofix.k65ad80b0')} {poolId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-white text-slate-900 border border-slate-200 px-4 py-2 text-sm font-medium hover:bg-slate-50 transition"
|
||||||
|
title={t('autofix.k6285753a')}
|
||||||
|
>
|
||||||
|
{t('autofix.k0ac84efe')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { BanknotesIcon, CalendarDaysIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
totalAmount: number
|
||||||
|
amountThisYear: number
|
||||||
|
amountThisMonth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, iconClassName, chipClassName }: { title: string; value: string; iconClassName: string; chipClassName: string }) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={`rounded-md p-2 shrink-0 ${chipClassName}`}>
|
||||||
|
<BanknotesIcon className={`h-5 w-5 ${iconClassName}`} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{title}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolManageStats({ t, totalAmount, amountThisYear, amountThisMonth }: Props) {
|
||||||
|
return (
|
||||||
|
<section className="grid [grid-template-columns:repeat(auto-fit,minmax(16rem,1fr))] gap-6 mb-8">
|
||||||
|
<StatCard
|
||||||
|
title={t('autofix.ke8b9f33c')}
|
||||||
|
value={`EUR ${totalAmount.toLocaleString()}`}
|
||||||
|
chipClassName="bg-slate-900"
|
||||||
|
iconClassName="text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="rounded-md bg-amber-600 p-2 shrink-0">
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{t('autofix.kaa8231ec')}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">EUR {amountThisYear.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative overflow-hidden rounded-2xl bg-white/90 px-6 py-5 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)] border border-white/80">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="rounded-md bg-green-600 p-2 shrink-0">
|
||||||
|
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-slate-600 break-words">{t('autofix.k86aa4f9c')}</p>
|
||||||
|
<p className="text-2xl font-semibold text-slate-900 break-words">EUR {amountThisMonth.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,117 @@
|
|||||||
|
import { PlusIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { PoolUser } from '../hooks/usePoolManageState'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
users: PoolUser[]
|
||||||
|
membersLoading: boolean
|
||||||
|
membersError: string
|
||||||
|
removeError: string
|
||||||
|
removingMemberId: string | null
|
||||||
|
isCore: boolean
|
||||||
|
onOpenSearch: () => void
|
||||||
|
onRemove: (userId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolMembersSection({
|
||||||
|
t,
|
||||||
|
users,
|
||||||
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
removeError,
|
||||||
|
removingMemberId,
|
||||||
|
isCore,
|
||||||
|
onOpenSearch,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-6 sm:p-7 backdrop-blur-md">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.kfd227aa9')}</h2>
|
||||||
|
<span className="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700">
|
||||||
|
{users.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onOpenSearch}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-5 w-5" />
|
||||||
|
{t('autofix.k750c1eb5')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{removeError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-700">
|
||||||
|
{removeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{membersLoading && <div className="text-center text-slate-500 italic py-8">{t('autofix.k5d4d494e')}</div>}
|
||||||
|
|
||||||
|
{membersError && !membersLoading && <div className="text-center text-red-600 py-8 break-words">{membersError}</div>}
|
||||||
|
|
||||||
|
{users.length === 0 && !membersLoading && !membersError && (
|
||||||
|
<div className="text-center text-slate-500 italic py-8">{t('autofix.kcbc17bbd')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.length > 0 && !membersLoading && (
|
||||||
|
<div className="overflow-x-auto rounded-xl border border-slate-200">
|
||||||
|
<table className="min-w-[760px] w-full divide-y divide-slate-200 text-sm">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.k5b2c4431')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.kb1438ed0')}</th>
|
||||||
|
<th className="px-4 py-3 text-left font-semibold text-slate-700">{t('autofix.k7bed84a7')}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-700">{isCore ? t('autofix.k22a3f7c1') : t('autofix.k69adf332')}</th>
|
||||||
|
<th className="px-4 py-3 text-right font-semibold text-slate-700" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
|
{users.map((poolUser) => (
|
||||||
|
<tr key={poolUser.id} className="hover:bg-slate-50 transition">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="h-7 w-7 rounded-full bg-slate-100 border border-slate-200 flex items-center justify-center text-xs font-bold text-slate-800 shrink-0">
|
||||||
|
{(poolUser.name?.[0] || '?').toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-slate-900 break-words">{poolUser.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 break-all">{poolUser.email}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-600 whitespace-nowrap">
|
||||||
|
{new Date(poolUser.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||||
|
poolUser.share > 0
|
||||||
|
? 'bg-green-50 text-green-700 border border-green-200'
|
||||||
|
: 'bg-slate-50 text-slate-500 border border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
EUR {poolUser.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(poolUser.id)}
|
||||||
|
disabled={removingMemberId === poolUser.id}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{removingMemberId === poolUser.id ? t('autofix.k18fd92a1') : t('autofix.k2ee90f41')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,189 @@
|
|||||||
|
import { MagnifyingGlassIcon, UsersIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserCandidate } from '../hooks/usePoolManageState'
|
||||||
|
|
||||||
|
type Translator = (key: string, params?: Record<string, string | number>) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
searchOpen: boolean
|
||||||
|
query: string
|
||||||
|
setQuery: (value: string) => void
|
||||||
|
loading: boolean
|
||||||
|
error: string
|
||||||
|
hasSearched: boolean
|
||||||
|
candidates: UserCandidate[]
|
||||||
|
selectedCandidates: Set<string>
|
||||||
|
savingMembers: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSearch: () => Promise<void>
|
||||||
|
onClear: () => void
|
||||||
|
onToggleCandidate: (id: string) => void
|
||||||
|
onAddSingle: (candidate: UserCandidate) => Promise<void>
|
||||||
|
onAddSelected: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoolSearchModal({
|
||||||
|
t,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
hasSearched,
|
||||||
|
candidates,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
onClose,
|
||||||
|
onSearch,
|
||||||
|
onClear,
|
||||||
|
onToggleCandidate,
|
||||||
|
onAddSingle,
|
||||||
|
onAddSelected,
|
||||||
|
}: Props) {
|
||||||
|
if (!searchOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||||
|
<div className="w-full max-w-3xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||||
|
<div className="px-6 py-5 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||||
|
<h4 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.ka6be28d2')}</h4>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-md text-slate-500 hover:bg-slate-100 hover:text-slate-700 transition"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
void onSearch()
|
||||||
|
}}
|
||||||
|
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-slate-100"
|
||||||
|
>
|
||||||
|
<div className="md:col-span-3">
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder={t('autofix.kb35549bb')}
|
||||||
|
className="w-full rounded-md bg-slate-50 border border-slate-300 text-sm text-slate-900 placeholder-slate-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-slate-900 focus:border-transparent transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 md:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || query.trim().length < 3}
|
||||||
|
className="flex-1 rounded-md bg-slate-900 hover:bg-slate-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||||
|
>
|
||||||
|
{loading ? t('autofix.kf5d7b213') : t('common.search')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
className="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k76f12c8a')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="px-6 pt-1 pb-3 text-right text-xs text-slate-500">{t('autofix.ke4c4a858')}</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
||||||
|
{error && <div className="text-sm text-red-600 mb-3 break-words">{error}</div>}
|
||||||
|
|
||||||
|
{!error && query.trim().length < 3 && (
|
||||||
|
<div className="py-8 text-sm text-slate-500 text-center">{t('autofix.kb87eb38b')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && hasSearched && loading && candidates.length === 0 && (
|
||||||
|
<ul className="space-y-0 divide-y divide-slate-200 border border-slate-200 rounded-md bg-slate-50">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<li key={index} className="animate-pulse px-4 py-3">
|
||||||
|
<div className="h-3.5 w-36 bg-slate-200 rounded" />
|
||||||
|
<div className="mt-2 h-3 w-56 bg-slate-100 rounded" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && hasSearched && !loading && candidates.length === 0 && (
|
||||||
|
<div className="py-8 text-sm text-slate-500 text-center">{t('autofix.k54f49724')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && candidates.length > 0 && (
|
||||||
|
<ul className="divide-y divide-slate-200 border border-slate-200 rounded-lg bg-white">
|
||||||
|
{candidates.map((candidate) => (
|
||||||
|
<li key={candidate.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-3 hover:bg-slate-50 transition">
|
||||||
|
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-slate-900 focus:ring-slate-900"
|
||||||
|
checked={selectedCandidates.has(candidate.id)}
|
||||||
|
onChange={() => onToggleCandidate(candidate.id)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UsersIcon className="h-4 w-4 text-slate-900" />
|
||||||
|
<span className="text-sm font-medium text-slate-900 break-words">{candidate.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-slate-600 break-all">{candidate.email}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => void onAddSingle(candidate)}
|
||||||
|
className="shrink-0 inline-flex items-center rounded-md bg-slate-900 hover:bg-slate-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k8c011ed3')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && candidates.length > 0 && (
|
||||||
|
<div className="pointer-events-none relative">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
||||||
|
<span className="h-5 w-5 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-3 border-t border-slate-100 flex flex-wrap items-center justify-between gap-3 bg-slate-50">
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{selectedCandidates.size > 0
|
||||||
|
? t('autofix.k3ab09ef0').replace('{count}', String(selectedCandidates.size))
|
||||||
|
: t('autofix.k2042d9f2')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k0f13bc22')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void onAddSelected()}
|
||||||
|
disabled={selectedCandidates.size === 0 || savingMembers}
|
||||||
|
className="text-sm rounded-md px-4 py-2 font-medium bg-slate-900 text-white hover:bg-slate-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{savingMembers ? t('autofix.k89bc3412') : t('autofix.k7e44aa19')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
343
src/app/admin/pool-management/manage/hooks/usePoolManageState.ts
Normal file
343
src/app/admin/pool-management/manage/hooks/usePoolManageState.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../../../store/authStore'
|
||||||
|
import { AdminAPI } from '../../../../utils/api'
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export type PoolUser = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
share: number
|
||||||
|
joinedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCandidate = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePoolManageState() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const token = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
!!user &&
|
||||||
|
((user as any)?.role === 'admin' ||
|
||||||
|
(user as any)?.userType === 'admin' ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
(user as any)?.roles?.includes?.('admin'))
|
||||||
|
|
||||||
|
const [authChecked, setAuthChecked] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user === null) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (user && !isAdmin) {
|
||||||
|
router.replace('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthChecked(true)
|
||||||
|
}, [user, isAdmin, router])
|
||||||
|
|
||||||
|
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
||||||
|
const poolName = searchParams.get('pool_name') ?? t('autofix.k78dc5a11')
|
||||||
|
const poolDescription = searchParams.get('description') ?? ''
|
||||||
|
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||||
|
const poolType = (searchParams.get('pool_type') as 'coffee' | 'other') || 'other'
|
||||||
|
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||||
|
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<PoolUser[]>([])
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false)
|
||||||
|
const [membersError, setMembersError] = useState('')
|
||||||
|
|
||||||
|
const [totalAmount, setTotalAmount] = useState(0)
|
||||||
|
const [amountThisYear, setAmountThisYear] = useState(0)
|
||||||
|
const [amountThisMonth, setAmountThisMonth] = useState(0)
|
||||||
|
|
||||||
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [candidates, setCandidates] = useState<UserCandidate[]>([])
|
||||||
|
const [hasSearched, setHasSearched] = useState(false)
|
||||||
|
const [selectedCandidates, setSelectedCandidates] = useState<Set<string>>(new Set())
|
||||||
|
const [savingMembers, setSavingMembers] = useState(false)
|
||||||
|
|
||||||
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
const [removeError, setRemoveError] = useState('')
|
||||||
|
const [removeConfirm, setRemoveConfirm] = useState<{ userId: string; label: string } | null>(null)
|
||||||
|
|
||||||
|
const isCore = useMemo(() => poolName === 'Core', [poolName])
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setMembersError('')
|
||||||
|
setMembersLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getPoolMembers(token, poolId)
|
||||||
|
const rows = Array.isArray(response?.members) ? response.members : []
|
||||||
|
|
||||||
|
const mapped: PoolUser[] = rows.map((row: any) => {
|
||||||
|
const name = row.company_name
|
||||||
|
? String(row.company_name)
|
||||||
|
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
name: name || String(row.email || '').trim() || t('autofix.k8dca3321'),
|
||||||
|
email: String(row.email || '').trim(),
|
||||||
|
share: Number(row.share ?? 0),
|
||||||
|
joinedAt: row.joined_at || new Date().toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setUsers(mapped)
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setMembersError(requestError?.message || t('autofix.k7021ad54'))
|
||||||
|
} finally {
|
||||||
|
setMembersLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, poolId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchMembers()
|
||||||
|
}, [fetchMembers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
const response = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = await response.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!cancelled && response.ok && body?.success) {
|
||||||
|
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
||||||
|
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
||||||
|
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stats are non-critical for page interaction.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadStats()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token, poolId])
|
||||||
|
|
||||||
|
const doSearch = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (normalizedQuery.length < 3) {
|
||||||
|
setHasSearched(false)
|
||||||
|
setCandidates([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setError(t('autofix.k53f7e9a1'))
|
||||||
|
setHasSearched(true)
|
||||||
|
setCandidates([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasSearched(true)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getUserList(token)
|
||||||
|
const list = Array.isArray(response?.users) ? response.users : []
|
||||||
|
const existingIds = new Set(users.map((poolUser) => String(poolUser.id)))
|
||||||
|
|
||||||
|
const mapped: UserCandidate[] = list
|
||||||
|
.filter((apiUser: any) => apiUser && apiUser.role !== 'admin' && apiUser.role !== 'super_admin')
|
||||||
|
.map((apiUser: any) => {
|
||||||
|
const name = apiUser.company_name
|
||||||
|
? String(apiUser.company_name)
|
||||||
|
: [apiUser.first_name, apiUser.last_name].filter(Boolean).join(' ').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(apiUser.id),
|
||||||
|
name: name || String(apiUser.email || '').trim() || t('autofix.k8dca3321'),
|
||||||
|
email: String(apiUser.email || '').trim(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((candidate) => !existingIds.has(candidate.id))
|
||||||
|
.filter((candidate) => `${candidate.name} ${candidate.email}`.toLowerCase().includes(normalizedQuery))
|
||||||
|
|
||||||
|
setCandidates(mapped)
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k9c4d2ab3'))
|
||||||
|
setCandidates([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [query, token, users])
|
||||||
|
|
||||||
|
const openSearch = () => {
|
||||||
|
setSearchOpen(true)
|
||||||
|
setQuery('')
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setError('')
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeSearch = () => {
|
||||||
|
setSearchOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearchQuery = () => {
|
||||||
|
setQuery('')
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCandidate = (id: string) => {
|
||||||
|
setSelectedCandidates((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addUserFromModal = async (candidate: UserCandidate) => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, [candidate.id])
|
||||||
|
await fetchMembers()
|
||||||
|
closeSearch()
|
||||||
|
clearSearchQuery()
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k3f7ca220'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSelectedUsers = async () => {
|
||||||
|
if (selectedCandidates.size === 0) return
|
||||||
|
|
||||||
|
const selectedList = candidates.filter((candidate) => selectedCandidates.has(candidate.id))
|
||||||
|
|
||||||
|
if (selectedList.length === 0 || !token || !poolId || poolId === 'pool-unknown') return
|
||||||
|
|
||||||
|
setSavingMembers(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userIds = selectedList.map((candidate) => candidate.id)
|
||||||
|
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
||||||
|
await fetchMembers()
|
||||||
|
closeSearch()
|
||||||
|
clearSearchQuery()
|
||||||
|
setCandidates([])
|
||||||
|
setHasSearched(false)
|
||||||
|
setSelectedCandidates(new Set())
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setError(requestError?.message || t('autofix.k90b5f8d1'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setSavingMembers(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const askRemoveMember = (userId: string) => {
|
||||||
|
const poolUser = users.find((entry) => entry.id === userId)
|
||||||
|
const label = poolUser?.name || poolUser?.email || 'this user'
|
||||||
|
setRemoveConfirm({ userId, label })
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmRemoveMember = async () => {
|
||||||
|
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
|
||||||
|
|
||||||
|
const userId = removeConfirm.userId
|
||||||
|
setRemoveError('')
|
||||||
|
setRemovingMemberId(userId)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
||||||
|
await fetchMembers()
|
||||||
|
} catch (requestError: any) {
|
||||||
|
setRemoveError(requestError?.message || t('autofix.k296db6a0'))
|
||||||
|
} finally {
|
||||||
|
setRemovingMemberId(null)
|
||||||
|
setRemoveConfirm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
router,
|
||||||
|
authChecked,
|
||||||
|
poolId,
|
||||||
|
poolName,
|
||||||
|
poolDescription,
|
||||||
|
poolPrice,
|
||||||
|
poolType,
|
||||||
|
poolIsActive,
|
||||||
|
poolCreatedAt,
|
||||||
|
isCore,
|
||||||
|
users,
|
||||||
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
totalAmount,
|
||||||
|
amountThisYear,
|
||||||
|
amountThisMonth,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
candidates,
|
||||||
|
hasSearched,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
removingMemberId,
|
||||||
|
removeError,
|
||||||
|
removeConfirm,
|
||||||
|
setRemoveConfirm,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
clearSearchQuery,
|
||||||
|
doSearch,
|
||||||
|
toggleCandidate,
|
||||||
|
addUserFromModal,
|
||||||
|
addSelectedUsers,
|
||||||
|
askRemoveMember,
|
||||||
|
confirmRemoveMember,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,592 +3,127 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../../i18n/useTranslation';
|
import { useTranslation } from '../../../i18n/useTranslation';
|
||||||
import React, { Suspense } from 'react' // CHANGED: add Suspense
|
import React, { Suspense } from 'react'
|
||||||
import Header from '../../../components/nav/Header'
|
import Header from '../../../components/nav/Header'
|
||||||
import Footer from '../../../components/Footer'
|
import Footer from '../../../components/Footer'
|
||||||
import { UsersIcon, PlusIcon, BanknotesIcon, CalendarDaysIcon, MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
|
||||||
import useAuthStore from '../../../store/authStore'
|
|
||||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||||
import { AdminAPI } from '../../../utils/api'
|
|
||||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||||
|
import { usePoolManageState } from './hooks/usePoolManageState'
|
||||||
type PoolUser = {
|
import PoolManageHeader from './components/PoolManageHeader'
|
||||||
id: string
|
import PoolManageStats from './components/PoolManageStats'
|
||||||
name: string
|
import PoolMembersSection from './components/PoolMembersSection'
|
||||||
email: string
|
import PoolSearchModal from './components/PoolSearchModal'
|
||||||
share: number
|
|
||||||
joinedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function PoolManagePageInner() {
|
function PoolManagePageInner() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const router = useRouter()
|
const {
|
||||||
const searchParams = useSearchParams()
|
router,
|
||||||
const user = useAuthStore(s => s.user)
|
authChecked,
|
||||||
const token = useAuthStore(s => s.accessToken)
|
poolId,
|
||||||
const isAdmin =
|
poolName,
|
||||||
!!user &&
|
poolDescription,
|
||||||
(
|
poolPrice,
|
||||||
(user as any)?.role === 'admin' ||
|
poolIsActive,
|
||||||
(user as any)?.userType === 'admin' ||
|
poolCreatedAt,
|
||||||
(user as any)?.isAdmin === true ||
|
isCore,
|
||||||
((user as any)?.roles?.includes?.('admin'))
|
users,
|
||||||
)
|
membersLoading,
|
||||||
|
membersError,
|
||||||
|
totalAmount,
|
||||||
|
amountThisYear,
|
||||||
|
amountThisMonth,
|
||||||
|
searchOpen,
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
candidates,
|
||||||
|
hasSearched,
|
||||||
|
selectedCandidates,
|
||||||
|
savingMembers,
|
||||||
|
removingMemberId,
|
||||||
|
removeError,
|
||||||
|
removeConfirm,
|
||||||
|
setRemoveConfirm,
|
||||||
|
openSearch,
|
||||||
|
closeSearch,
|
||||||
|
clearSearchQuery,
|
||||||
|
doSearch,
|
||||||
|
toggleCandidate,
|
||||||
|
addUserFromModal,
|
||||||
|
addSelectedUsers,
|
||||||
|
askRemoveMember,
|
||||||
|
confirmRemoveMember,
|
||||||
|
} = usePoolManageState()
|
||||||
|
|
||||||
// Auth gate
|
|
||||||
const [authChecked, setAuthChecked] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (user === null) {
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (user && !isAdmin) {
|
|
||||||
router.replace('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setAuthChecked(true)
|
|
||||||
}, [user, isAdmin, router])
|
|
||||||
|
|
||||||
// Read pool data from query params with fallbacks (hooks must be before any return)
|
|
||||||
const poolId = searchParams.get('id') ?? 'pool-unknown'
|
|
||||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
|
||||||
const poolDescription = searchParams.get('description') ?? ''
|
|
||||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
|
||||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
|
||||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
|
||||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
|
||||||
|
|
||||||
// Members (no dummy data)
|
|
||||||
const [users, setUsers] = React.useState<PoolUser[]>([])
|
|
||||||
const [membersLoading, setMembersLoading] = React.useState(false)
|
|
||||||
const [membersError, setMembersError] = React.useState<string>('')
|
|
||||||
|
|
||||||
// Stats (no dummy data)
|
|
||||||
const [totalAmount, setTotalAmount] = React.useState<number>(0)
|
|
||||||
const [amountThisYear, setAmountThisYear] = React.useState<number>(0)
|
|
||||||
const [amountThisMonth, setAmountThisMonth] = React.useState<number>(0)
|
|
||||||
|
|
||||||
// Search modal state
|
|
||||||
const [searchOpen, setSearchOpen] = React.useState(false)
|
|
||||||
const [query, setQuery] = React.useState('')
|
|
||||||
const [loading, setLoading] = React.useState(false)
|
|
||||||
const [error, setError] = React.useState<string>('')
|
|
||||||
const [candidates, setCandidates] = React.useState<Array<{ id: string; name: string; email: string }>>([])
|
|
||||||
const [hasSearched, setHasSearched] = React.useState(false)
|
|
||||||
const [selectedCandidates, setSelectedCandidates] = React.useState<Set<string>>(new Set())
|
|
||||||
const [savingMembers, setSavingMembers] = React.useState(false)
|
|
||||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
|
||||||
const [removeError, setRemoveError] = React.useState<string>('')
|
|
||||||
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
|
|
||||||
|
|
||||||
async function fetchMembers() {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setMembersError('')
|
|
||||||
setMembersLoading(true)
|
|
||||||
try {
|
|
||||||
const resp = await AdminAPI.getPoolMembers(token, poolId)
|
|
||||||
const rows = Array.isArray(resp?.members) ? resp.members : []
|
|
||||||
const mapped: PoolUser[] = rows.map((row: any) => {
|
|
||||||
const name = row.company_name
|
|
||||||
? String(row.company_name)
|
|
||||||
: [row.first_name, row.last_name].filter(Boolean).join(' ').trim()
|
|
||||||
return {
|
|
||||||
id: String(row.id),
|
|
||||||
name: name || String(row.email || '').trim() || 'Unnamed user',
|
|
||||||
email: String(row.email || '').trim(),
|
|
||||||
share: Number(row.share ?? 0),
|
|
||||||
joinedAt: row.joined_at || new Date().toISOString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setUsers(mapped)
|
|
||||||
} catch (e: any) {
|
|
||||||
setMembersError(e?.message || 'Failed to load pool members.')
|
|
||||||
} finally {
|
|
||||||
setMembersLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
void fetchMembers()
|
|
||||||
}, [token, poolId])
|
|
||||||
|
|
||||||
// Fetch pool inflow stats
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
let cancelled = false
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
|
||||||
const res = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => ({}))
|
|
||||||
if (!cancelled && res.ok && body?.success) {
|
|
||||||
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
|
||||||
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
|
||||||
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore — stats are non-critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void loadStats()
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [token, poolId])
|
|
||||||
|
|
||||||
// Early return AFTER all hooks are declared to keep consistent order
|
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
|
|
||||||
async function doSearch() {
|
|
||||||
setError('')
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (q.length < 3) {
|
|
||||||
setHasSearched(false)
|
|
||||||
setCandidates([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
setError('Authentication required.')
|
|
||||||
setHasSearched(true)
|
|
||||||
setCandidates([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasSearched(true)
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const resp = await AdminAPI.getUserList(token)
|
|
||||||
const list = Array.isArray(resp?.users) ? resp.users : []
|
|
||||||
|
|
||||||
const existingIds = new Set(users.map(u => String(u.id)))
|
|
||||||
|
|
||||||
const mapped: Array<{ id: string; name: string; email: string }> = list
|
|
||||||
.filter((u: any) => u && u.role !== 'admin' && u.role !== 'super_admin')
|
|
||||||
.map((u: any) => {
|
|
||||||
const name = u.company_name
|
|
||||||
? String(u.company_name)
|
|
||||||
: [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
|
||||||
return {
|
|
||||||
id: String(u.id),
|
|
||||||
name: name || String(u.email || '').trim() || 'Unnamed user',
|
|
||||||
email: String(u.email || '').trim()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((u: { id: string; name: string; email: string }) => !existingIds.has(u.id))
|
|
||||||
.filter((u: { id: string; name: string; email: string }) => {
|
|
||||||
const hay = `${u.name} ${u.email}`.toLowerCase()
|
|
||||||
return hay.includes(q)
|
|
||||||
})
|
|
||||||
|
|
||||||
setCandidates(mapped)
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to search users.')
|
|
||||||
setCandidates([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUserFromModal(u: { id: string; name: string; email: string }) {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setSavingMembers(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
await AdminAPI.addPoolMembers(token, poolId, [u.id])
|
|
||||||
await fetchMembers()
|
|
||||||
setSearchOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
setCandidates([])
|
|
||||||
setHasSearched(false)
|
|
||||||
setSelectedCandidates(new Set())
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to add user.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setSavingMembers(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCandidate(id: string) {
|
|
||||||
setSelectedCandidates(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addSelectedUsers() {
|
|
||||||
if (selectedCandidates.size === 0) return
|
|
||||||
const selectedList = candidates.filter(c => selectedCandidates.has(c.id))
|
|
||||||
if (selectedList.length === 0) return
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
|
||||||
setSavingMembers(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const userIds = selectedList.map(u => u.id)
|
|
||||||
await AdminAPI.addPoolMembers(token, poolId, userIds)
|
|
||||||
await fetchMembers()
|
|
||||||
setSearchOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
setCandidates([])
|
|
||||||
setHasSearched(false)
|
|
||||||
setSelectedCandidates(new Set())
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e?.message || 'Failed to add users.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setSavingMembers(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMember(userId: string) {
|
|
||||||
const user = users.find(u => u.id === userId)
|
|
||||||
const label = user?.name || user?.email || 'this user'
|
|
||||||
setRemoveConfirm({ userId, label })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmRemoveMember() {
|
|
||||||
if (!token || !poolId || poolId === 'pool-unknown' || !removeConfirm) return
|
|
||||||
const userId = removeConfirm.userId
|
|
||||||
setRemoveError('')
|
|
||||||
setRemovingMemberId(userId)
|
|
||||||
try {
|
|
||||||
await AdminAPI.removePoolMembers(token, poolId, [userId])
|
|
||||||
await fetchMembers()
|
|
||||||
} catch (e: any) {
|
|
||||||
setRemoveError(e?.message || 'Failed to remove user from pool.')
|
|
||||||
} finally {
|
|
||||||
setRemovingMemberId(null)
|
|
||||||
setRemoveConfirm(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCore = poolName === 'Core'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-gradient-to-tr from-blue-50 via-white to-blue-100'}`}>
|
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.14),transparent_42%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.12),transparent_36%),linear-gradient(135deg,#eff6ff_0%,#ffffff_44%,#dbeafe_100%)]'}`}>
|
||||||
<Header />
|
<Header />
|
||||||
{/* main wrapper: avoid high z-index stacking */}
|
<main className="flex-1 py-8 px-4 sm:px-6 xl:px-10 relative z-0">
|
||||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
<div className="max-w-[1820px] mx-auto relative z-0">
|
||||||
<div className="max-w-7xl mx-auto relative z-0">
|
<PoolManageHeader
|
||||||
{/* Header (remove sticky/z-10) */}
|
t={t}
|
||||||
<header className={`backdrop-blur border-b py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0 ${
|
poolId={poolId}
|
||||||
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/90 border-blue-100'
|
poolName={poolName}
|
||||||
}`}>
|
poolDescription={poolDescription}
|
||||||
{isCore && (
|
poolPrice={poolPrice}
|
||||||
<div className="inline-flex items-center gap-1.5 self-start rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-2">
|
poolIsActive={poolIsActive}
|
||||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>{t('autofix.k39437388')}</div>
|
poolCreatedAt={poolCreatedAt}
|
||||||
)}
|
isCore={isCore}
|
||||||
<div className="flex items-center justify-between">
|
onBack={() => router.push('/admin/pool-management')}
|
||||||
<div className="flex items-center gap-3">
|
/>
|
||||||
<div className={`h-10 w-10 rounded-lg border flex items-center justify-center ${
|
|
||||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
|
||||||
}`}>
|
|
||||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className={`text-3xl font-extrabold tracking-tight ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{poolName}</h1>
|
|
||||||
<p className={`text-sm ${isCore ? 'text-amber-700' : 'text-blue-700'}`}>
|
|
||||||
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600 flex-wrap">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
|
||||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
|
||||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Price/capsule (gross): € {Number(poolPrice || 0).toFixed(2)}{isCore ? ' × each member' : ''}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-gray-500">ID: {poolId}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Back to Pool Management */}
|
|
||||||
<button
|
|
||||||
onClick={() => router.push('/admin/pool-management')}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-white text-blue-900 border border-blue-200 px-4 py-2 text-sm font-medium hover:bg-blue-50 transition"
|
|
||||||
title={t('autofix.k6285753a')}
|
|
||||||
>{t('autofix.k0ac84efe')}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Stats (now zero until backend wired) */}
|
<PoolManageStats
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
t={t}
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
totalAmount={totalAmount}
|
||||||
<div className="flex items-center gap-3">
|
amountThisYear={amountThisYear}
|
||||||
<div className="rounded-md bg-blue-900 p-2">
|
amountThisMonth={amountThisMonth}
|
||||||
<BanknotesIcon className="h-5 w-5 text-white" />
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">{t('autofix.ke8b9f33c')}</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {totalAmount.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-md bg-amber-600 p-2">
|
|
||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">{t('autofix.kaa8231ec')}</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisYear.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="rounded-md bg-green-600 p-2">
|
|
||||||
<CalendarDaysIcon className="h-5 w-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">{t('autofix.k86aa4f9c')}</p>
|
|
||||||
<p className="text-2xl font-semibold text-gray-900">€ {amountThisMonth.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Unified Members card: add button + list */}
|
<PoolMembersSection
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
t={t}
|
||||||
<div className="flex items-center justify-between mb-4">
|
users={users}
|
||||||
<div className="flex items-center gap-3">
|
membersLoading={membersLoading}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
membersError={membersError}
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
removeError={removeError}
|
||||||
{users.length}
|
removingMemberId={removingMemberId}
|
||||||
</span>
|
isCore={isCore}
|
||||||
</div>
|
onOpenSearch={openSearch}
|
||||||
<button
|
onRemove={askRemoveMember}
|
||||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
/>
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />{t('autofix.k750c1eb5')}</button>
|
|
||||||
</div>
|
|
||||||
{removeError && (
|
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{removeError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{membersLoading && (
|
|
||||||
<div className="text-center text-gray-500 italic py-8">{t('autofix.k5d4d494e')}</div>
|
|
||||||
)}
|
|
||||||
{membersError && !membersLoading && (
|
|
||||||
<div className="text-center text-red-600 py-8">{membersError}</div>
|
|
||||||
)}
|
|
||||||
{users.length === 0 && !membersLoading && !membersError && (
|
|
||||||
<div className="text-center text-gray-500 italic py-8">{t('autofix.kcbc17bbd')}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length > 0 && !membersLoading && (
|
|
||||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Name</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Email</th>
|
|
||||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">{t('autofix.k7bed84a7')}</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold text-gray-700">{isCore ? 'Total Earned' : 'Share'}</th>
|
|
||||||
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
|
||||||
{users.map(u => (
|
|
||||||
<tr key={u.id} className="hover:bg-gray-50 transition">
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-7 w-7 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-xs font-bold text-blue-800">
|
|
||||||
{(u.name?.[0] || '?').toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-gray-900">{u.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">{u.email}</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">
|
|
||||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
|
||||||
u.share > 0
|
|
||||||
? 'bg-green-50 text-green-700 border border-green-200'
|
|
||||||
: 'bg-gray-50 text-gray-500 border border-gray-200'
|
|
||||||
}`}>
|
|
||||||
€ {u.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => removeMember(u.id)}
|
|
||||||
disabled={removingMemberId === u.id}
|
|
||||||
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{/* Search Modal (keep above with high z) */}
|
<PoolSearchModal
|
||||||
{searchOpen && (
|
t={t}
|
||||||
<div className="fixed inset-0 z-50">
|
searchOpen={searchOpen}
|
||||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
query={query}
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
setQuery={setQuery}
|
||||||
<div className="w-full max-w-2xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
loading={loading}
|
||||||
{/* Header */}
|
error={error}
|
||||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
hasSearched={hasSearched}
|
||||||
<h4 className="text-lg font-semibold text-blue-900">{t('autofix.ka6be28d2')}</h4>
|
candidates={candidates}
|
||||||
<button
|
selectedCandidates={selectedCandidates}
|
||||||
onClick={() => setSearchOpen(false)}
|
savingMembers={savingMembers}
|
||||||
className="p-1.5 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700 transition"
|
onClose={closeSearch}
|
||||||
aria-label="Close"
|
onSearch={doSearch}
|
||||||
>
|
onClear={clearSearchQuery}
|
||||||
<XMarkIcon className="h-5 w-5" />
|
onToggleCandidate={toggleCandidate}
|
||||||
</button>
|
onAddSingle={addUserFromModal}
|
||||||
</div>
|
onAddSelected={addSelectedUsers}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form
|
|
||||||
onSubmit={e => { e.preventDefault(); void doSearch(); }}
|
|
||||||
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-gray-100"
|
|
||||||
>
|
|
||||||
<div className="md:col-span-3">
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
value={query}
|
|
||||||
onChange={e => setQuery(e.target.value)}
|
|
||||||
placeholder={t('autofix.kb35549bb')}
|
|
||||||
className="w-full rounded-md bg-gray-50 border border-gray-300 text-sm text-gray-900 placeholder-gray-400 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-900 focus:border-transparent transition"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 md:col-span-2">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || query.trim().length < 3}
|
|
||||||
className="flex-1 rounded-md bg-blue-900 hover:bg-blue-800 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
|
||||||
>
|
|
||||||
{loading ? 'Searching…' : 'Search'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setQuery(''); setError(''); }}
|
|
||||||
className="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div className="px-6 pt-1 pb-3 text-right text-xs text-gray-500">{t('autofix.ke4c4a858')}</div>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
|
||||||
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
|
||||||
{!error && query.trim().length < 3 && (
|
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">{t('autofix.kb87eb38b')}</div>
|
|
||||||
)}
|
|
||||||
{!error && hasSearched && loading && candidates.length === 0 && (
|
|
||||||
<ul className="space-y-0 divide-y divide-gray-200 border border-gray-200 rounded-md bg-gray-50">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<li key={i} className="animate-pulse px-4 py-3">
|
|
||||||
<div className="h-3.5 w-36 bg-gray-200 rounded" />
|
|
||||||
<div className="mt-2 h-3 w-56 bg-gray-100 rounded" />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{!error && hasSearched && !loading && candidates.length === 0 && (
|
|
||||||
<div className="py-8 text-sm text-gray-500 text-center">{t('autofix.k54f49724')}</div>
|
|
||||||
)}
|
|
||||||
{!error && candidates.length > 0 && (
|
|
||||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-lg bg-white">
|
|
||||||
{candidates.map(u => (
|
|
||||||
<li key={u.id} className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-gray-50 transition">
|
|
||||||
<label className="min-w-0 flex items-start gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
|
||||||
checked={selectedCandidates.has(u.id)}
|
|
||||||
onChange={() => toggleCandidate(u.id)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UsersIcon className="h-4 w-4 text-blue-900" />
|
|
||||||
<span className="text-sm font-medium text-gray-900 truncate max-w-[200px]">{u.name}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-0.5 text-[11px] text-gray-600 break-all">{u.email}</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
onClick={() => addUserFromModal(u)}
|
|
||||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-900 hover:bg-blue-800 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
{loading && candidates.length > 0 && (
|
|
||||||
<div className="pointer-events-none relative">
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60">
|
|
||||||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-6 py-3 border-t border-gray-100 flex items-center justify-between bg-gray-50">
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{selectedCandidates.size > 0 ? `${selectedCandidates.size} selected` : 'No users selected'}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchOpen(false)}
|
|
||||||
className="text-sm rounded-md px-4 py-2 font-medium bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={addSelectedUsers}
|
|
||||||
disabled={selectedCandidates.size === 0 || savingMembers}
|
|
||||||
className="text-sm rounded-md px-4 py-2 font-medium bg-blue-900 text-white hover:bg-blue-800 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{savingMembers ? 'Adding…' : 'Add Selected'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={Boolean(removeConfirm)}
|
open={Boolean(removeConfirm)}
|
||||||
pending={Boolean(removingMemberId)}
|
pending={Boolean(removingMemberId)}
|
||||||
intent="danger"
|
intent="danger"
|
||||||
title={t('autofix.k959fb1a6')}
|
title={t('autofix.k959fb1a6')}
|
||||||
description={`This will remove ${removeConfirm?.label || 'this user'} from the pool.`}
|
description={t('autofix.k7c40d832').replace('{label}', removeConfirm?.label || t('autofix.k74122df0'))}
|
||||||
confirmText="Remove"
|
confirmText={t('autofix.k2ee90f41')}
|
||||||
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
onClose={() => { if (!removingMemberId) setRemoveConfirm(null) }}
|
||||||
onConfirm={confirmRemoveMember}
|
onConfirm={confirmRemoveMember}
|
||||||
/>
|
/>
|
||||||
@ -597,19 +132,22 @@ function PoolManagePageInner() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CHANGED: Suspense wrapper required for useSearchParams() during prerender
|
function PoolManagePageFallback() {
|
||||||
export default function PoolManagePage() {
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#0F172A] mx-auto mb-3" />
|
||||||
<p className="text-[#4A4A4A]">Loading...</p>
|
<p className="text-[#4A4A4A]">{t('autofix.k79d12c2e')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
|
||||||
|
export default function PoolManagePage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PoolManagePageFallback />}>
|
||||||
<PoolManagePageInner />
|
<PoolManagePageInner />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -6,97 +6,36 @@ import { useTranslation } from '../../i18n/useTranslation';
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import Header from '../../components/nav/Header'
|
import Header from '../../components/nav/Header'
|
||||||
import Footer from '../../components/Footer'
|
import Footer from '../../components/Footer'
|
||||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { useAdminPools } from './hooks/getlist'
|
import { useAdminPools } from './hooks/getlist'
|
||||||
import useAuthStore from '../../store/authStore'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
|
||||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||||
|
import { usePoolManagementPage } from './hooks/usePoolManagementPage'
|
||||||
type Pool = {
|
import PoolManagementHeader from './components/PoolManagementHeader'
|
||||||
id: string
|
import PoolManagementGrid from './components/PoolManagementGrid'
|
||||||
pool_name: string
|
|
||||||
description?: string
|
|
||||||
price?: number
|
|
||||||
pool_type?: 'coffee' | 'other'
|
|
||||||
is_active?: boolean
|
|
||||||
membersCount: number
|
|
||||||
createdAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PoolManagementPage() {
|
export default function PoolManagementPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
|
||||||
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
|
||||||
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
|
||||||
|
|
||||||
// Replace local fetch with hook
|
|
||||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||||
const [pools, setPools] = React.useState<Pool[]>([])
|
const {
|
||||||
const [showInactive, setShowInactive] = React.useState(false)
|
router,
|
||||||
|
authChecked,
|
||||||
React.useEffect(() => {
|
archiveError,
|
||||||
if (!loading && !error) {
|
poolStatusConfirm,
|
||||||
setPools(initialPools)
|
poolStatusPending,
|
||||||
}
|
pools,
|
||||||
}, [initialPools, loading, error])
|
showInactive,
|
||||||
|
setShowInactive,
|
||||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
filteredPools,
|
||||||
|
requestArchive,
|
||||||
async function handleArchive(poolId: string) {
|
requestActivate,
|
||||||
setPoolStatusConfirm({ poolId, action: 'archive' })
|
closePoolStatusConfirm,
|
||||||
}
|
confirmPoolStatusChange,
|
||||||
|
} = usePoolManagementPage({
|
||||||
async function handleSetActive(poolId: string) {
|
initialPools,
|
||||||
setPoolStatusConfirm({ poolId, action: 'activate' })
|
loading,
|
||||||
}
|
error,
|
||||||
|
refresh,
|
||||||
async function confirmPoolStatusChange() {
|
})
|
||||||
if (!poolStatusConfirm) return
|
|
||||||
const { poolId, action } = poolStatusConfirm
|
|
||||||
setPoolStatusPending(true)
|
|
||||||
setArchiveError('')
|
|
||||||
try {
|
|
||||||
const res = action === 'archive' ? await setPoolInactive(poolId) : await setPoolActive(poolId)
|
|
||||||
if (res.ok) {
|
|
||||||
await refresh?.()
|
|
||||||
} else {
|
|
||||||
setArchiveError(res.message || (action === 'archive' ? 'Failed to deactivate pool.' : 'Failed to activate pool.'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setPoolStatusPending(false)
|
|
||||||
setPoolStatusConfirm(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = useAuthStore(s => s.user)
|
|
||||||
const isAdmin =
|
|
||||||
!!user &&
|
|
||||||
(
|
|
||||||
(user as any)?.role === 'admin' ||
|
|
||||||
(user as any)?.userType === 'admin' ||
|
|
||||||
(user as any)?.isAdmin === true ||
|
|
||||||
((user as any)?.roles?.includes?.('admin'))
|
|
||||||
)
|
|
||||||
|
|
||||||
// NEW: block rendering until we decide access
|
|
||||||
const [authChecked, setAuthChecked] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
// When user is null -> unauthenticated; undefined means not loaded yet (store default may be null in this app).
|
|
||||||
if (user === null) {
|
|
||||||
router.replace('/login')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (user && !isAdmin) {
|
|
||||||
router.replace('/')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// user exists and is admin
|
|
||||||
setAuthChecked(true)
|
|
||||||
}, [user, isAdmin, router])
|
|
||||||
|
|
||||||
// Early return: render nothing until authorized, prevents any flash
|
// Early return: render nothing until authorized, prevents any flash
|
||||||
if (!authChecked) return null
|
if (!authChecked) return null
|
||||||
@ -104,107 +43,26 @@ export default function PoolManagementPage() {
|
|||||||
// Remove Access Denied overlay; render normal content
|
// Remove Access Denied overlay; render normal content
|
||||||
return (
|
return (
|
||||||
<PageTransitionEffect>
|
<PageTransitionEffect>
|
||||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
<div className="min-h-screen flex flex-col bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.14),transparent_42%),radial-gradient(circle_at_bottom_right,_rgba(14,165,233,0.12),transparent_36%),linear-gradient(135deg,#eff6ff_0%,#ffffff_44%,#dbeafe_100%)]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
<main className="flex-1 py-8 px-4 sm:px-6 xl:px-10 relative z-0">
|
||||||
<div className="max-w-7xl mx-auto relative z-0">
|
<div className="max-w-[1820px] mx-auto relative z-0">
|
||||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8 relative z-0">
|
<PoolManagementHeader
|
||||||
<div className="flex items-center justify-between">
|
t={t}
|
||||||
<div>
|
showInactive={showInactive}
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k21440f8a')}</h1>
|
onShowActive={() => setShowInactive(false)}
|
||||||
<p className="text-lg text-blue-700 mt-2">{t('autofix.k67391c88')}</p>
|
onShowInactive={() => setShowInactive(true)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-600">{t('autofix.k0dd01c1c')}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInactive(false)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${!showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
|
||||||
>{t('autofix.k15843a06')}</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowInactive(true)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition ${showInactive ? 'bg-blue-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}
|
|
||||||
>{t('autofix.kb5e0b861')}</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Pools List card */}
|
<PoolManagementGrid
|
||||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
t={t}
|
||||||
<div className="flex items-center justify-between mb-4">
|
pools={pools}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k5857ef79')}</h2>
|
filteredPools={filteredPools}
|
||||||
<span className="text-sm text-gray-600">{pools.length} total</span>
|
loading={loading}
|
||||||
</div>
|
error={error}
|
||||||
|
archiveError={archiveError}
|
||||||
{/* Show archive errors */}
|
showInactive={showInactive}
|
||||||
{archiveError && (
|
onManage={(pool) => {
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{archiveError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow p-5">
|
|
||||||
<div className="animate-pulse space-y-3">
|
|
||||||
<div className="h-5 w-1/2 bg-gray-200 rounded" />
|
|
||||||
<div className="h-4 w-3/4 bg-gray-200 rounded" />
|
|
||||||
<div className="h-4 w-2/3 bg-gray-100 rounded" />
|
|
||||||
<div className="h-8 w-full bg-gray-100 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{filteredPools.map(pool => {
|
|
||||||
const isCore = pool.pool_name === 'Core'
|
|
||||||
return (
|
|
||||||
<article key={pool.id} className={`rounded-2xl border shadow p-5 flex flex-col relative z-0 ${
|
|
||||||
isCore
|
|
||||||
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
|
||||||
: 'bg-white border-gray-100'
|
|
||||||
}`}>
|
|
||||||
{isCore && (
|
|
||||||
<div className="absolute -top-2.5 left-4 inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm">
|
|
||||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>{t('autofix.k87e4b9a2')}</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center ${
|
|
||||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
|
||||||
}`}>
|
|
||||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
|
||||||
</div>
|
|
||||||
<h3 className={`text-lg font-semibold ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{pool.pool_name}</h3>
|
|
||||||
</div>
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
|
||||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
|
|
||||||
{!pool.is_active ? 'Inactive' : 'Active'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Members</span>
|
|
||||||
<div className="font-medium text-gray-900">{pool.membersCount}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-gray-500">Created</span>
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{new Date(pool.createdAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-gray-200 text-gray-700 hover:bg-gray-300 transition"
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
id: String(pool.id),
|
id: String(pool.id),
|
||||||
pool_name: pool.pool_name ?? '',
|
pool_name: pool.pool_name ?? '',
|
||||||
@ -216,36 +74,9 @@ export default function PoolManagementPage() {
|
|||||||
})
|
})
|
||||||
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
router.push(`/admin/pool-management/manage?${params.toString()}`)
|
||||||
}}
|
}}
|
||||||
>
|
onArchive={requestArchive}
|
||||||
Manage
|
onActivate={requestActivate}
|
||||||
</button>
|
/>
|
||||||
{!pool.is_active ? (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-green-100 text-green-800 hover:bg-green-200 transition"
|
|
||||||
onClick={() => handleSetActive(pool.id)}
|
|
||||||
title={t('autofix.kd40c4f86')}
|
|
||||||
>{t('autofix.ke697b8cb')}</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-amber-100 text-amber-800 hover:bg-amber-200 transition"
|
|
||||||
onClick={() => handleArchive(pool.id)}
|
|
||||||
title={t('autofix.ke19afb3d')}
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{filteredPools.length === 0 && !loading && !error && (
|
|
||||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
|
||||||
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -253,14 +84,14 @@ export default function PoolManagementPage() {
|
|||||||
open={Boolean(poolStatusConfirm)}
|
open={Boolean(poolStatusConfirm)}
|
||||||
pending={poolStatusPending}
|
pending={poolStatusPending}
|
||||||
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
|
intent={poolStatusConfirm?.action === 'archive' ? 'danger' : 'default'}
|
||||||
title={poolStatusConfirm?.action === 'archive' ? 'Archive pool?' : 'Activate pool?'}
|
title={poolStatusConfirm?.action === 'archive' ? t('autofix.k0b84e6aa') : t('autofix.k9ad214be')}
|
||||||
description={
|
description={
|
||||||
poolStatusConfirm?.action === 'archive'
|
poolStatusConfirm?.action === 'archive'
|
||||||
? 'Users will no longer be able to join or use this pool while archived.'
|
? t('autofix.k3fe81c2a')
|
||||||
: 'This pool will be active again and available for use.'
|
: t('autofix.k1d6c33f1')
|
||||||
}
|
}
|
||||||
confirmText={poolStatusConfirm?.action === 'archive' ? 'Archive' : 'Set Active'}
|
confirmText={poolStatusConfirm?.action === 'archive' ? t('autofix.kf3b0c221') : t('autofix.k8fa13d9b')}
|
||||||
onClose={() => { if (!poolStatusPending) setPoolStatusConfirm(null) }}
|
onClose={closePoolStatusConfirm}
|
||||||
onConfirm={confirmPoolStatusChange}
|
onConfirm={confirmPoolStatusChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
27
src/app/admin/pool-management/utils/poolDescriptionKey.ts
Normal file
27
src/app/admin/pool-management/utils/poolDescriptionKey.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export function resolvePoolDescriptionKey(
|
||||||
|
poolName?: string,
|
||||||
|
poolType?: 'coffee' | 'other',
|
||||||
|
rawDescription?: string
|
||||||
|
): string {
|
||||||
|
const raw = (rawDescription || '').trim()
|
||||||
|
|
||||||
|
// Keep existing translation keys untouched.
|
||||||
|
if (raw.includes('.') && /^[A-Za-z0-9_.-]+$/.test(raw)) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedName = (poolName || '').trim().toLowerCase()
|
||||||
|
if (normalizedName === 'core') {
|
||||||
|
return 'autofix.kcf73e90d'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poolType === 'coffee') {
|
||||||
|
return 'autofix.k20f6ac90'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (poolType === 'other') {
|
||||||
|
return 'autofix.k4bc91d55'
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'autofix.kf0c9a38d'
|
||||||
|
}
|
||||||
14
src/app/admin/pool-management/utils/translateMaybeKey.ts
Normal file
14
src/app/admin/pool-management/utils/translateMaybeKey.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
export function translateMaybeKey(t: Translator, value?: string, fallback = ''): string {
|
||||||
|
if (!value) return fallback
|
||||||
|
|
||||||
|
const candidate = value.trim()
|
||||||
|
if (!candidate) return fallback
|
||||||
|
|
||||||
|
const looksLikeKey = candidate.includes('.') && /^[A-Za-z0-9_.-]+$/.test(candidate)
|
||||||
|
if (!looksLikeKey) return value
|
||||||
|
|
||||||
|
const translated = t(candidate)
|
||||||
|
return translated && translated !== candidate ? translated : value
|
||||||
|
}
|
||||||
@ -138,18 +138,21 @@ export default function AdminSubscriptionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-6 px-6 rounded-2xl shadow-lg mb-8">
|
<header className="sticky top-0 z-10 rounded-[30px] border border-white/80 bg-white/88 backdrop-blur py-6 px-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] mb-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Coffees</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
<p className="text-lg text-blue-700 mt-2">{t('autofix.k875f4054')}</p>
|
Admin
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-4xl font-black text-slate-950 tracking-tight break-words">Coffees</h1>
|
||||||
|
<p className="text-lg text-slate-600 mt-2 break-words">{t('autofix.k875f4054')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/subscriptions/createSubscription"
|
href="/admin/subscriptions/createSubscription"
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
|
className="inline-flex items-center gap-2 rounded-2xl bg-slate-900 hover:bg-slate-800 text-slate-50 px-5 py-3 text-base font-semibold shadow transition self-start sm:self-auto"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>{t('autofix.kaa30f0cd')}</Link>
|
<svg className="w-5 h-5" fill="none" stroke="currentColor"><path d="M7 1a1 1 0 0 1 2 0v5h5a1 1 0 1 1 0 2H9v5a1 1 0 1 1-2 0V8H2a1 1 0 1 1 0-2h5V1z"/></svg>{t('autofix.kaa30f0cd')}</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -160,14 +163,14 @@ export default function AdminSubscriptionsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shipping Fees */}
|
{/* Shipping Fees */}
|
||||||
<section className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg p-6">
|
<section className="mb-8 rounded-[28px] border border-white/80 bg-white/88 backdrop-blur shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] p-6">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-blue-900">Shipping Fees (ABO)</h2>
|
<h2 className="text-xl font-semibold text-slate-900 break-words">Shipping Fees (ABO)</h2>
|
||||||
<p className="mt-1 text-sm text-gray-600">{t('autofix.k027bd82e')}</p>
|
<p className="mt-1 text-sm text-slate-600 break-words">{t('autofix.k027bd82e')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg bg-gray-50 px-4 py-2 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 hover:bg-gray-100 shadow transition self-start"
|
className="inline-flex items-center rounded-2xl bg-white px-4 py-2 text-xs font-medium text-slate-700 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 shadow transition self-start"
|
||||||
onClick={loadShippingFees}
|
onClick={loadShippingFees}
|
||||||
disabled={shippingFeesLoading}
|
disabled={shippingFeesLoading}
|
||||||
>
|
>
|
||||||
@ -190,14 +193,14 @@ export default function AdminSubscriptionsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pieceCount}
|
key={pieceCount}
|
||||||
className="rounded-xl border border-gray-100 bg-white ring-1 ring-inset ring-gray-100 p-4"
|
className="rounded-2xl border border-white/80 bg-white/85 ring-1 ring-inset ring-slate-100 p-4"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm font-semibold text-gray-900">{pieceCount} pieces</div>
|
<div className="text-sm font-semibold text-slate-900 break-words">{pieceCount} pieces</div>
|
||||||
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
|
{typeof current?.price === 'number' && Number.isFinite(current.price) ? (
|
||||||
<div className="text-xs text-gray-500">Current: €{formatPriceDraft(current.price)}</div>
|
<div className="text-xs text-slate-500 break-words">Current: €{formatPriceDraft(current.price)}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{savedAt ? (
|
{savedAt ? (
|
||||||
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
|
<div className="text-xs text-emerald-700 bg-emerald-50 ring-1 ring-inset ring-emerald-200 px-2 py-0.5 rounded-full">
|
||||||
@ -206,9 +209,9 @@ export default function AdminSubscriptionsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{fieldError ? (
|
{fieldError ? (
|
||||||
<div className="mt-2 text-xs text-red-700">{fieldError}</div>
|
<div className="mt-2 text-xs text-red-700 break-words">{fieldError}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 text-xs text-gray-500">Enter a price in EUR (≥ 0).</div>
|
<div className="mt-2 text-xs text-slate-500 break-words">Enter a price in EUR (≥ 0).</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -217,7 +220,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500">€</span>
|
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-gray-500">€</span>
|
||||||
<input
|
<input
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className={`w-40 rounded-lg border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-200 ${
|
className={`w-40 rounded-2xl border px-8 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-slate-200 ${
|
||||||
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300'
|
fieldError ? 'border-red-300 ring-1 ring-red-200' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
value={draft}
|
value={draft}
|
||||||
@ -233,7 +236,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${
|
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-semibold shadow transition ${
|
||||||
saving
|
saving
|
||||||
? 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
? 'bg-gray-200 text-gray-600 cursor-not-allowed'
|
||||||
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'
|
: 'bg-slate-900 text-slate-50 hover:bg-slate-800'
|
||||||
}`}
|
}`}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => saveShippingFee(pieceCount)}
|
onClick={() => saveShippingFee(pieceCount)}
|
||||||
@ -250,12 +253,12 @@ export default function AdminSubscriptionsPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="col-span-full text-sm text-gray-700">{t('autofix.k832387c5')}</div>
|
<div className="col-span-full text-sm text-slate-700 break-words">{t('autofix.k832387c5')}</div>
|
||||||
)}
|
)}
|
||||||
{!loading && items.map(item => (
|
{!loading && items.map(item => (
|
||||||
<div key={item.id} className="rounded-2xl border border-gray-100 bg-white shadow-lg p-6 flex flex-col gap-3 hover:shadow-xl transition">
|
<div key={item.id} className="rounded-[28px] border border-white/80 bg-white/88 backdrop-blur shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] p-6 flex flex-col gap-3 hover:shadow-[0_26px_70px_-36px_rgba(15,23,42,0.35)] transition">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="text-xl font-semibold text-blue-900">{item.title}</h3>
|
<h3 className="text-xl font-semibold text-slate-900 break-words">{item.title}</h3>
|
||||||
{availabilityBadge(!!item.state)}
|
{availabilityBadge(!!item.state)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
|
<div className="mt-3 w-full h-40 rounded-xl ring-1 ring-gray-200 overflow-hidden flex items-center justify-center bg-gray-50">
|
||||||
@ -265,16 +268,16 @@ export default function AdminSubscriptionsPage() {
|
|||||||
<PhotoIcon className="w-12 h-12 text-gray-300" />
|
<PhotoIcon className="w-12 h-12 text-gray-300" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm text-gray-800 line-clamp-4">{item.description}</p>
|
<p className="mt-3 text-sm text-slate-700 break-words line-clamp-4">{item.description}</p>
|
||||||
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
|
<dl className="mt-4 grid grid-cols-1 gap-y-2 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-gray-500">Price</dt>
|
<dt className="text-slate-500">Price</dt>
|
||||||
<dd className="font-medium text-gray-900">
|
<dd className="font-medium text-slate-900 break-words">
|
||||||
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
{item.currency || 'EUR'} {Number.isFinite(Number(item.price)) ? Number(item.price).toFixed(2) : String(item.price)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{item.billing_interval && item.interval_count ? (
|
{item.billing_interval && item.interval_count ? (
|
||||||
<div className="text-gray-600">
|
<div className="text-slate-600 break-words">
|
||||||
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
|
<span className="text-xs">Subscription billing: {item.billing_interval} (x{item.interval_count})</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@ -284,7 +287,7 @@ export default function AdminSubscriptionsPage() {
|
|||||||
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
|
className={`inline-flex items-center rounded-lg px-4 py-2 text-xs font-medium shadow transition
|
||||||
${item.state
|
${item.state
|
||||||
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
|
? 'bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-200 hover:bg-amber-100'
|
||||||
: 'bg-blue-900 text-blue-50 hover:bg-blue-800'}`}
|
: 'bg-slate-900 text-slate-50 hover:bg-slate-800'}`}
|
||||||
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
|
onClick={async () => { await setProductState(item.id, !item.state); await load(); }}
|
||||||
>
|
>
|
||||||
{item.state ? 'Disable' : 'Enable'}
|
{item.state ? 'Disable' : 'Enable'}
|
||||||
@ -305,28 +308,28 @@ export default function AdminSubscriptionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{!loading && !items.length && (
|
{!loading && !items.length && (
|
||||||
<div className="col-span-full py-8 text-center text-sm text-gray-500">{t('autofix.k8c75468c')}</div>
|
<div className="col-span-full py-8 text-center text-sm text-slate-500 break-words">{t('autofix.k8c75468c')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Confirm Delete Modal */}
|
{/* Confirm Delete Modal */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<div className="fixed inset-0 z-50">
|
<div className="fixed inset-0 z-50">
|
||||||
<div className="absolute inset-0 bg-black/30" onClick={() => setDeleteTarget(null)} />
|
<div className="absolute inset-0 bg-black/35 backdrop-blur-sm" onClick={() => setDeleteTarget(null)} />
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md rounded-2xl bg-white shadow-xl ring-1 ring-gray-200">
|
<div className="w-full max-w-md rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]">
|
||||||
<div className="px-6 pt-6">
|
<div className="px-6 pt-6">
|
||||||
<h3 className="text-lg font-semibold text-blue-900">{t('autofix.kddd4832f')}</h3>
|
<h3 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.kddd4832f')}</h3>
|
||||||
<p className="mt-2 text-sm text-gray-700">You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.</p>
|
<p className="mt-2 text-sm text-slate-700 break-words">You are about to delete the coffee “{deleteTarget.title}”. This action cannot be undone.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
<div className="px-6 pb-6 pt-4 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-medium text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
|
className="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-medium text-slate-700 ring-1 ring-inset ring-slate-300 hover:bg-slate-50"
|
||||||
onClick={() => setDeleteTarget(null)}
|
onClick={() => setDeleteTarget(null)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="inline-flex items-center rounded-lg px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
className="inline-flex items-center rounded-2xl px-4 py-2 text-sm font-semibold text-white bg-red-600 hover:bg-red-500 shadow"
|
||||||
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
onClick={async () => { await deleteProduct(deleteTarget.id); setDeleteTarget(null); await load(); }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
export function UserManagementInitialLoading({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)] flex items-center justify-center">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full border-2 border-slate-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-800 font-medium">{t('autofix.kfd6e0974')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagementAccessDenied({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)] flex items-center justify-center px-4">
|
||||||
|
<div className="mx-auto w-full max-w-xl rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
||||||
|
<p className="text-slate-600">{t('autofix.k661c032b')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserRole, UserStatus, UserType } from '../hooks/useUserManagementPageState'
|
||||||
|
import {
|
||||||
|
getUserStatusLabel,
|
||||||
|
getUserTypeLabel,
|
||||||
|
getUserRoleLabel,
|
||||||
|
type ManagedUserType,
|
||||||
|
type ManagedUserRole,
|
||||||
|
} from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TFunction
|
||||||
|
search: string
|
||||||
|
setSearch: (value: string) => void
|
||||||
|
fType: 'all' | UserType
|
||||||
|
setFType: (value: 'all' | UserType) => void
|
||||||
|
fStatus: 'all' | UserStatus
|
||||||
|
setFStatus: (value: 'all' | UserStatus) => void
|
||||||
|
fRole: 'all' | UserRole
|
||||||
|
setFRole: (value: 'all' | UserRole) => void
|
||||||
|
statusOptions: UserStatus[]
|
||||||
|
typeOptions: UserType[]
|
||||||
|
roleOptions: UserRole[]
|
||||||
|
onSubmit: () => void
|
||||||
|
onExportCsv: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(t: TFunction, value: UserType): string {
|
||||||
|
return getUserTypeLabel(t, value as ManagedUserType)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(t: TFunction, value: UserStatus): string {
|
||||||
|
if (value === 'disabled') return t('autofix.k2fc06d90')
|
||||||
|
return getUserStatusLabel(t, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleLabel(t: TFunction, value: UserRole): string {
|
||||||
|
return getUserRoleLabel(t, value as ManagedUserRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementFilters({
|
||||||
|
t,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
statusOptions,
|
||||||
|
typeOptions,
|
||||||
|
roleOptions,
|
||||||
|
onSubmit,
|
||||||
|
onExportCsv,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
onSubmit()
|
||||||
|
}}
|
||||||
|
className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.kd1f35ccf')}</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="sr-only">{t('autofix.k91a76444')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 pl-10 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fType}
|
||||||
|
onChange={(event) => setFType(event.target.value as 'all' | UserType)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k10e2568f')}</option>
|
||||||
|
{typeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>{getTypeLabel(t, type)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={(event) => setFStatus(event.target.value as 'all' | UserStatus)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k2e8f3110')}</option>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<option key={status} value={status}>{getStatusLabel(t, status)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<select
|
||||||
|
value={fRole}
|
||||||
|
onChange={(event) => setFRole(event.target.value as 'all' | UserRole)}
|
||||||
|
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k110bae43')}</option>
|
||||||
|
{roleOptions.map((role) => (
|
||||||
|
<option key={role} value={role}>{getRoleLabel(t, role)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExportCsv}
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-900 hover:bg-slate-50 transition"
|
||||||
|
title={t('autofix.k1387f81e')}
|
||||||
|
>
|
||||||
|
{t('autofix.k1521a376')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
{t('autofix.k3ae7a0c0')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
export default function UserManagementHeader({ t }: { t: TFunction }) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k6f4e16a2')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">
|
||||||
|
{t('autofix.k1af97a07')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm sm:text-base text-slate-600 mt-2 break-words">
|
||||||
|
{t('autofix.k79e1c459')}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Stats = {
|
||||||
|
total: number
|
||||||
|
admins: number
|
||||||
|
guests: number
|
||||||
|
personal: number
|
||||||
|
company: number
|
||||||
|
active: number
|
||||||
|
pending: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, valueClassName }: { label: string; value: number; valueClassName?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-white/80 bg-white/90 p-5 shadow-[0_24px_70px_-44px_rgba(15,23,42,0.3)] backdrop-blur text-center">
|
||||||
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
|
<div className={`text-2xl font-semibold mt-1 ${valueClassName || 'text-slate-900'}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementStats({ t, stats }: { t: TFunction; stats: Stats }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-5">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k20a59c89')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-xl border border-amber-200 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-100 transition"
|
||||||
|
onClick={() => window.location.assign('/admin/user-verify')}
|
||||||
|
>
|
||||||
|
{t('autofix.k2f78fabe')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid [grid-template-columns:repeat(auto-fit,minmax(10rem,1fr))] gap-4">
|
||||||
|
<StatCard label={t('autofix.kb324fb25')} value={stats.total} />
|
||||||
|
<StatCard label={t('autofix.k107562d0')} value={stats.admins} valueClassName="text-indigo-700" />
|
||||||
|
<StatCard label={t('autofix.k1da4ef38')} value={stats.guests} valueClassName="text-amber-700" />
|
||||||
|
<StatCard label={t('autofix.kf1882b08')} value={stats.personal} valueClassName="text-sky-700" />
|
||||||
|
<StatCard label={t('autofix.k56f0ef1f')} value={stats.company} valueClassName="text-violet-700" />
|
||||||
|
<StatCard label={t('autofix.kf6afbb1f')} value={stats.active} valueClassName="text-green-700" />
|
||||||
|
<StatCard label={t('autofix.k8f278f58')} value={stats.pending} valueClassName="text-amber-700" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
src/app/admin/user-management/components/UserManagementTable.tsx
Normal file
194
src/app/admin/user-management/components/UserManagementTable.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { PencilSquareIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { UserRole, UserRow, UserStatus, UserType } from '../hooks/useUserManagementPageState'
|
||||||
|
import {
|
||||||
|
getUserStatusBadgeClass,
|
||||||
|
getUserStatusLabel,
|
||||||
|
getUserTypeBadgeClass,
|
||||||
|
getUserTypeLabel,
|
||||||
|
getUserRoleBadgeClass,
|
||||||
|
getUserRoleLabel,
|
||||||
|
type ManagedUserStatus,
|
||||||
|
type ManagedUserType,
|
||||||
|
type ManagedUserRole,
|
||||||
|
} from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
type TFunction = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: TFunction
|
||||||
|
loading: boolean
|
||||||
|
users: UserRow[]
|
||||||
|
totalFiltered: number
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (nextPage: number) => void
|
||||||
|
onEdit: (id: string) => void
|
||||||
|
normalizeStatus: (status: string) => UserStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(text: string, color: 'blue' | 'amber' | 'green' | 'gray' | 'rose' | 'indigo' | 'violet') {
|
||||||
|
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide'
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
blue: 'bg-sky-100 text-sky-700',
|
||||||
|
amber: 'bg-amber-100 text-amber-700',
|
||||||
|
green: 'bg-green-100 text-green-700',
|
||||||
|
gray: 'bg-slate-100 text-slate-700',
|
||||||
|
rose: 'bg-rose-100 text-rose-700',
|
||||||
|
indigo: 'bg-indigo-100 text-indigo-700',
|
||||||
|
violet: 'bg-violet-100 text-violet-700',
|
||||||
|
}
|
||||||
|
return <span className={`${base} ${colorMap[color]}`}>{text}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: UserStatus, t: TFunction) {
|
||||||
|
if (status === 'disabled') return badge(t('autofix.k2fc06d90'), 'gray')
|
||||||
|
const managedStatus = status as ManagedUserStatus
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide border ${getUserStatusBadgeClass(managedStatus)}`}
|
||||||
|
>
|
||||||
|
{getUserStatusLabel(t, managedStatus)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadge(type: UserType, t: TFunction) {
|
||||||
|
const managedType = type as ManagedUserType
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ${getUserTypeBadgeClass(managedType)}`}>
|
||||||
|
{getUserTypeLabel(t, managedType)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleBadge(role: UserRole, t: TFunction) {
|
||||||
|
const managedRole = role as ManagedUserRole
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold tracking-wide ${getUserRoleBadgeClass(managedRole)}`}>
|
||||||
|
{getUserRoleLabel(t, managedRole)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagementTable({
|
||||||
|
t,
|
||||||
|
loading,
|
||||||
|
users,
|
||||||
|
totalFiltered,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onEdit,
|
||||||
|
normalizeStatus,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/90 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.3)] backdrop-blur space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k10ccb626')}</h2>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{t('autofix.k2e41c8dc').replace('{current}', String(users.length)).replace('{total}', String(totalFiltered))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-slate-200/70 bg-white/70 p-1">
|
||||||
|
<table className="min-w-full text-sm rounded-xl overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50 text-left text-slate-900">
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k91f49568')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kec4fe9ef')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k81c0b74b')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.ked760737')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kf123704b')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.kb24782ec')}</th>
|
||||||
|
<th className="px-4 py-3 font-semibold">{t('autofix.k0afbbac4')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-slate-700">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
<span>{t('autofix.k7fa2c4af')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-slate-600">{t('autofix.k748bf541')}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => {
|
||||||
|
const displayName = user.user_type === 'company'
|
||||||
|
? user.company_name || t('autofix.k835f1c86')
|
||||||
|
: `${user.first_name || t('autofix.k76870ea8')} ${user.last_name || t('autofix.k2bf5e6ec')}`
|
||||||
|
|
||||||
|
const initials = user.user_type === 'company'
|
||||||
|
? (user.company_name?.[0] || 'C').toUpperCase()
|
||||||
|
: `${user.first_name?.[0] || 'U'}${user.last_name?.[0] || 'U'}`.toUpperCase()
|
||||||
|
|
||||||
|
const createdDate = new Date(user.created_at).toLocaleDateString()
|
||||||
|
const lastLoginDate = user.last_login_at ? new Date(user.last_login_at).toLocaleDateString() : t('autofix.k768f3f4a')
|
||||||
|
const normalizedStatus = normalizeStatus(user.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-slate-900 to-slate-700 text-white text-xs font-semibold shadow">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900 leading-tight">{displayName}</div>
|
||||||
|
<div className="text-[11px] text-slate-600">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">{typeBadge(user.user_type, t)}</td>
|
||||||
|
<td className="px-4 py-4">{statusBadge(normalizedStatus, t)}</td>
|
||||||
|
<td className="px-4 py-4">{roleBadge(user.role, t)}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-900">{createdDate}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-600 italic">{lastLoginDate}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(user.id.toString())}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 hover:bg-slate-100 text-slate-900 px-3 py-2 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<PencilSquareIcon className="h-4 w-4" />
|
||||||
|
{t('autofix.k9f8d7a4f')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-600">
|
||||||
|
{t('autofix.kf03c39b7').replace('{page}', String(page)).replace('{pages}', String(totalPages)).replace('{total}', String(totalFiltered))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg border border-slate-200 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.kdb27a82d')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-lg border border-slate-200 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.ka8ea17b8')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
export type ManagedUserStatus = 'active' | 'pending' | 'suspended' | 'inactive' | 'archived'
|
||||||
|
export type ManagedUserType = 'personal' | 'company'
|
||||||
|
export type ManagedUserRole = 'user' | 'admin' | 'guest' | 'super_admin'
|
||||||
|
|
||||||
|
export const USER_STATUS_FILTER_OPTIONS: ManagedUserStatus[] = [
|
||||||
|
'active',
|
||||||
|
'pending',
|
||||||
|
'suspended',
|
||||||
|
'inactive',
|
||||||
|
'archived',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getUserStatusLabelKey(status: ManagedUserStatus): string {
|
||||||
|
if (status === 'active') return 'autofix.kf6afbb1f'
|
||||||
|
if (status === 'pending') return 'autofix.k8f278f58'
|
||||||
|
if (status === 'suspended') return 'autofix.k18bf2a04'
|
||||||
|
if (status === 'inactive') return 'autofix.k2fc06d90'
|
||||||
|
return 'autofix.k9129ea6f'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatusLabel(t: (key: string) => string, status: ManagedUserStatus): string {
|
||||||
|
return t(getUserStatusLabelKey(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserStatusBadgeClass(status: ManagedUserStatus): string {
|
||||||
|
if (status === 'active') return 'bg-green-100 text-green-800 border-green-200'
|
||||||
|
if (status === 'pending') return 'bg-amber-100 text-amber-800 border-amber-200'
|
||||||
|
if (status === 'suspended') return 'bg-rose-100 text-rose-800 border-rose-200'
|
||||||
|
return 'bg-slate-100 text-slate-800 border-slate-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeLabelKey(type: ManagedUserType): string {
|
||||||
|
return type === 'personal' ? 'autofix.kf9463361' : 'autofix.k7eedf98b'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeLabel(t: (key: string) => string, type: ManagedUserType): string {
|
||||||
|
return t(getUserTypeLabelKey(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserTypeBadgeClass(type: ManagedUserType): string {
|
||||||
|
return type === 'personal' ? 'bg-sky-100 text-sky-700' : 'bg-violet-100 text-violet-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleLabelKey(role: ManagedUserRole): string {
|
||||||
|
if (role === 'admin') return 'autofix.k03f9899f'
|
||||||
|
if (role === 'guest') return 'autofix.kdcdca454'
|
||||||
|
if (role === 'super_admin') return 'userDetailModal.superAdmin'
|
||||||
|
return 'autofix.k2bf5e6ec'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleLabel(t: (key: string) => string, role: ManagedUserRole): string {
|
||||||
|
return t(getUserRoleLabelKey(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserRoleBadgeClass(role: ManagedUserRole): string {
|
||||||
|
if (role === 'admin' || role === 'super_admin') return 'bg-indigo-100 text-indigo-700'
|
||||||
|
if (role === 'guest') return 'bg-amber-100 text-amber-700'
|
||||||
|
return 'bg-slate-100 text-slate-700'
|
||||||
|
}
|
||||||
@ -0,0 +1,225 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useAdminUsers } from '../../../hooks/useAdminUsers'
|
||||||
|
import { AdminAPI } from '../../../utils/api'
|
||||||
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { USER_STATUS_FILTER_OPTIONS } from '../constants/userStatusPresentation'
|
||||||
|
|
||||||
|
export type UserType = 'personal' | 'company'
|
||||||
|
export type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
||||||
|
export type UserRole = 'user' | 'admin' | 'guest'
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: number
|
||||||
|
email: string
|
||||||
|
user_type: UserType
|
||||||
|
role: UserRole
|
||||||
|
created_at: string
|
||||||
|
last_login_at: string | null
|
||||||
|
status: string
|
||||||
|
is_admin_verified: number
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
company_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS: UserStatus[] = [...USER_STATUS_FILTER_OPTIONS]
|
||||||
|
export const TYPE_OPTIONS: UserType[] = ['personal', 'company']
|
||||||
|
export const ROLE_OPTIONS: UserRole[] = ['user', 'admin', 'guest']
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
function normalizeStatus(status: string): UserStatus {
|
||||||
|
const allowed: UserStatus[] = ['active', 'pending', 'suspended', 'inactive', 'archived']
|
||||||
|
return allowed.includes(status as UserStatus) ? (status as UserStatus) : 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCsvValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return '""'
|
||||||
|
const escaped = String(value).replace(/"/g, '""')
|
||||||
|
return `"${escaped}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUserManagementPageState() {
|
||||||
|
const { isAdmin } = useAdminUsers()
|
||||||
|
const token = useAuthStore((state) => state.accessToken)
|
||||||
|
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [users, setUsers] = useState<UserRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||||
|
const [fStatus, setFStatus] = useState<'all' | UserStatus>('all')
|
||||||
|
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const fetchAllUsers = useCallback(async () => {
|
||||||
|
if (!token || !isAdmin) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await AdminAPI.getUserList(token)
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || 'Failed to fetch users')
|
||||||
|
}
|
||||||
|
setUsers(response.users || [])
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to fetch users'
|
||||||
|
setError(message)
|
||||||
|
console.error('useUserManagementPageState.fetchAllUsers error:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [token, isAdmin])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClient && token && isAdmin) {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}
|
||||||
|
}, [isClient, token, isAdmin, fetchAllUsers])
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
return users.filter((user) => {
|
||||||
|
const firstName = user.first_name || ''
|
||||||
|
const lastName = user.last_name || ''
|
||||||
|
const companyName = user.company_name || ''
|
||||||
|
const fullName = user.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||||
|
const normalizedStatus = normalizeStatus(user.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
(fType === 'all' || user.user_type === fType) &&
|
||||||
|
(fStatus === 'all' || normalizedStatus === fStatus) &&
|
||||||
|
(fRole === 'all' || user.role === fRole) &&
|
||||||
|
(!search.trim() ||
|
||||||
|
user.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
fullName.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [users, search, fType, fStatus, fRole])
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(filteredUsers.length / PAGE_SIZE)), [filteredUsers.length])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) {
|
||||||
|
setPage(totalPages)
|
||||||
|
}
|
||||||
|
}, [page, totalPages])
|
||||||
|
|
||||||
|
const currentUsers = useMemo(() => {
|
||||||
|
const start = (page - 1) * PAGE_SIZE
|
||||||
|
return filteredUsers.slice(start, start + PAGE_SIZE)
|
||||||
|
}, [filteredUsers, page])
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => ({
|
||||||
|
total: users.length,
|
||||||
|
admins: users.filter((user) => user.role === 'admin').length,
|
||||||
|
guests: users.filter((user) => user.role === 'guest').length,
|
||||||
|
personal: users.filter((user) => user.user_type === 'personal').length,
|
||||||
|
company: users.filter((user) => user.user_type === 'company').length,
|
||||||
|
active: users.filter((user) => normalizeStatus(user.status) === 'active').length,
|
||||||
|
pending: users.filter((user) => normalizeStatus(user.status) === 'pending').length,
|
||||||
|
}),
|
||||||
|
[users]
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyFilters = () => {
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportCsv = () => {
|
||||||
|
const headers = [
|
||||||
|
'ID',
|
||||||
|
'Email',
|
||||||
|
'Type',
|
||||||
|
'Role',
|
||||||
|
'Status',
|
||||||
|
'Admin Verified',
|
||||||
|
'First Name',
|
||||||
|
'Last Name',
|
||||||
|
'Company Name',
|
||||||
|
'Created At',
|
||||||
|
'Last Login At',
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = filteredUsers.map((user) => {
|
||||||
|
return [
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.user_type,
|
||||||
|
user.role,
|
||||||
|
normalizeStatus(user.status),
|
||||||
|
user.is_admin_verified === 1 ? 'yes' : 'no',
|
||||||
|
user.first_name || '',
|
||||||
|
user.last_name || '',
|
||||||
|
user.company_name || '',
|
||||||
|
new Date(user.created_at).toISOString(),
|
||||||
|
user.last_login_at ? new Date(user.last_login_at).toISOString() : '',
|
||||||
|
]
|
||||||
|
.map(toCsvValue)
|
||||||
|
.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
const csv = [headers.join(','), ...rows].join('\r\n')
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `users_${new Date().toISOString().slice(0, 10)}.csv`
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserDetail = (userId: string) => {
|
||||||
|
setSelectedUserId(userId)
|
||||||
|
setIsDetailModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUserDetail = () => {
|
||||||
|
setIsDetailModalOpen(false)
|
||||||
|
setSelectedUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isClient,
|
||||||
|
isAdmin,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stats,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
totalPages,
|
||||||
|
filteredUsers,
|
||||||
|
currentUsers,
|
||||||
|
fetchAllUsers,
|
||||||
|
applyFilters,
|
||||||
|
exportCsv,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
normalizeStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,510 +1,127 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
|
||||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
|
import UserManagementHeader from './components/UserManagementHeader'
|
||||||
|
import UserManagementStats from './components/UserManagementStats'
|
||||||
|
import UserManagementFilters from './components/UserManagementFilters'
|
||||||
|
import UserManagementTable from './components/UserManagementTable'
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
UserManagementAccessDenied,
|
||||||
PencilSquareIcon,
|
UserManagementInitialLoading,
|
||||||
ExclamationTriangleIcon
|
} from './components/UserManagementAccessStates'
|
||||||
} from '@heroicons/react/24/outline'
|
import {
|
||||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
ROLE_OPTIONS,
|
||||||
import { AdminAPI } from '../../utils/api'
|
STATUS_OPTIONS,
|
||||||
import useAuthStore from '../../store/authStore'
|
TYPE_OPTIONS,
|
||||||
|
useUserManagementPageState,
|
||||||
type UserType = 'personal' | 'company'
|
} from './hooks/useUserManagementPageState'
|
||||||
type UserStatus = 'active' | 'pending' | 'disabled' | 'inactive' | 'suspended' | 'archived'
|
|
||||||
type UserRole = 'user' | 'admin' | 'guest'
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: number
|
|
||||||
email: string
|
|
||||||
user_type: UserType
|
|
||||||
role: UserRole
|
|
||||||
created_at: string
|
|
||||||
last_login_at: string | null
|
|
||||||
status: string
|
|
||||||
is_admin_verified: number
|
|
||||||
first_name?: string
|
|
||||||
last_name?: string
|
|
||||||
company_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUSES: UserStatus[] = ['active','pending','disabled','inactive']
|
|
||||||
const TYPES: UserType[] = ['personal','company']
|
|
||||||
const ROLES: UserRole[] = ['user','admin','guest']
|
|
||||||
|
|
||||||
export default function AdminUserManagementPage() {
|
export default function AdminUserManagementPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation()
|
||||||
const { isAdmin } = useAdminUsers()
|
const {
|
||||||
const token = useAuthStore(state => state.accessToken)
|
isClient,
|
||||||
const [isClient, setIsClient] = useState(false)
|
isAdmin,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
stats,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
totalPages,
|
||||||
|
filteredUsers,
|
||||||
|
currentUsers,
|
||||||
|
fetchAllUsers,
|
||||||
|
applyFilters,
|
||||||
|
exportCsv,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
normalizeStatus,
|
||||||
|
} = useUserManagementPageState()
|
||||||
|
|
||||||
// State for all users (not just pending)
|
|
||||||
const [allUsers, setAllUsers] = useState<User[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Handle client-side mounting
|
|
||||||
useEffect(() => {
|
|
||||||
setIsClient(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Fetch all users from backend
|
|
||||||
const fetchAllUsers = useCallback(async () => {
|
|
||||||
if (!token || !isAdmin) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await AdminAPI.getUserList(token)
|
|
||||||
if (response.success) {
|
|
||||||
setAllUsers(response.users || [])
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || 'Failed to fetch users')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch users'
|
|
||||||
setError(errorMessage)
|
|
||||||
console.error('AdminUserManagement.fetchAllUsers error:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [token, isAdmin])
|
|
||||||
|
|
||||||
// Load users on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (isClient && isAdmin && token) {
|
|
||||||
fetchAllUsers()
|
|
||||||
}
|
|
||||||
}, [fetchAllUsers, isClient])
|
|
||||||
|
|
||||||
// Filter hooks - must be declared before conditional returns
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [fType, setFType] = useState<'all'|UserType>('all')
|
|
||||||
const [fStatus, setFStatus] = useState<'all'|UserStatus>('all')
|
|
||||||
const [fRole, setFRole] = useState<'all'|UserRole>('all')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const PAGE_SIZE = 10
|
|
||||||
|
|
||||||
// Modal state
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return allUsers.filter(u => {
|
|
||||||
const firstName = u.first_name || ''
|
|
||||||
const lastName = u.last_name || ''
|
|
||||||
const companyName = u.company_name || ''
|
|
||||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
|
||||||
|
|
||||||
// Use backend status directly for filtering
|
|
||||||
const allowedStatuses: UserStatus[] = ['pending','active','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
|
|
||||||
return (
|
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
|
||||||
(fStatus === 'all' || userStatus === fStatus) &&
|
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
|
||||||
(
|
|
||||||
!search.trim() ||
|
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
fullName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [allUsers, search, fType, fStatus, fRole])
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
|
||||||
const current = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
|
||||||
|
|
||||||
// Move stats calculation above all conditional returns to avoid hook order errors
|
|
||||||
const stats = useMemo(() => ({
|
|
||||||
total: allUsers.length,
|
|
||||||
admins: allUsers.filter(u => u.role === 'admin').length,
|
|
||||||
guests: allUsers.filter(u => u.role === 'guest').length,
|
|
||||||
personal: allUsers.filter(u => u.user_type === 'personal').length,
|
|
||||||
company: allUsers.filter(u => u.user_type === 'company').length,
|
|
||||||
active: allUsers.filter(u => u.status === 'active').length,
|
|
||||||
pending: allUsers.filter(u => u.status === 'pending').length,
|
|
||||||
}), [allUsers])
|
|
||||||
|
|
||||||
// Show loading during SSR/initial client render
|
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserManagementInitialLoading t={t} />
|
||||||
<div className="text-center">
|
|
||||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-blue-900">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access check (only after client-side hydration)
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserManagementAccessDenied t={t} />
|
||||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
|
||||||
<p className="text-gray-600">{t('autofix.k661c032b')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyFilter = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NEW: CSV export utilities (exports all filtered results, not only current page)
|
|
||||||
const toCsvValue = (v: unknown) => {
|
|
||||||
if (v === null || v === undefined) return '""'
|
|
||||||
const s = String(v).replace(/"/g, '""')
|
|
||||||
return `"${s}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportCsv = () => {
|
|
||||||
const headers = [
|
|
||||||
'ID','Email','Type','Role','Status','Admin Verified',
|
|
||||||
'First Name','Last Name','Company Name','Created At','Last Login At'
|
|
||||||
]
|
|
||||||
const rows = filtered.map(u => {
|
|
||||||
// Use backend status directly
|
|
||||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
return [
|
|
||||||
u.id,
|
|
||||||
u.email,
|
|
||||||
u.user_type,
|
|
||||||
u.role,
|
|
||||||
userStatus,
|
|
||||||
u.is_admin_verified === 1 ? 'yes' : 'no',
|
|
||||||
u.first_name || '',
|
|
||||||
u.last_name || '',
|
|
||||||
u.company_name || '',
|
|
||||||
new Date(u.created_at).toISOString(),
|
|
||||||
u.last_login_at ? new Date(u.last_login_at).toISOString() : ''
|
|
||||||
].map(toCsvValue).join(',')
|
|
||||||
})
|
|
||||||
const csv = [headers.join(','), ...rows].join('\r\n')
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = url
|
|
||||||
a.download = `users_${new Date().toISOString().slice(0,10)}.csv`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = (text: string, color: 'blue'|'amber'|'green'|'gray'|'rose'|'indigo'|'purple') => {
|
|
||||||
const base = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide'
|
|
||||||
const map: Record<string,string> = {
|
|
||||||
blue: 'bg-blue-100 text-blue-700',
|
|
||||||
amber: 'bg-amber-100 text-amber-700',
|
|
||||||
green: 'bg-green-100 text-green-700',
|
|
||||||
gray: 'bg-gray-100 text-gray-700',
|
|
||||||
rose: 'bg-rose-100 text-rose-700',
|
|
||||||
indigo: 'bg-indigo-100 text-indigo-700',
|
|
||||||
purple: 'bg-purple-100 text-purple-700'
|
|
||||||
}
|
|
||||||
return <span className={`${base} ${map[color]}`}>{text}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusBadge = (s: UserStatus) =>
|
|
||||||
s==='active' ? badge('Active','green')
|
|
||||||
: s==='pending' ? badge('Pending','amber')
|
|
||||||
: s==='suspended' ? badge('Suspended','rose')
|
|
||||||
: s==='archived' ? badge('Archived','gray')
|
|
||||||
: s==='inactive' ? badge('Inactive','gray')
|
|
||||||
: badge('Unknown','gray')
|
|
||||||
|
|
||||||
const typeBadge = (t: UserType) =>
|
|
||||||
t==='personal' ? badge('Personal','blue') : badge('Company','purple')
|
|
||||||
|
|
||||||
const roleBadge = (r: UserRole) =>
|
|
||||||
r==='admin' ? badge('Admin','indigo') : r==='guest' ? badge('Guest','amber') : badge('User','gray')
|
|
||||||
|
|
||||||
// Action handler for opening edit modal
|
|
||||||
const onEdit = (id: string) => {
|
|
||||||
setSelectedUserId(id)
|
|
||||||
setIsDetailModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout contentClassName="flex-1 relative w-full">
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1820px] mx-auto px-4 sm:px-6 xl:px-10 py-8 space-y-5">
|
||||||
{/* Header */}
|
<UserManagementHeader t={t} />
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.k1af97a07')}</h1>
|
|
||||||
<p className="text-lg text-blue-700 mt-2">{t('autofix.k79e1c459')}</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Statistic Section + Verify Button */}
|
<UserManagementStats t={t} stats={stats} />
|
||||||
<div className="mb-8 flex flex-col sm:flex-row sm:items-center gap-6">
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-7 gap-6 flex-1">
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">{t('autofix.kb324fb25')}</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-900">{stats.total}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Admins</div>
|
|
||||||
<div className="text-xl font-semibold text-indigo-700">{stats.admins}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Guests</div>
|
|
||||||
<div className="text-xl font-semibold text-amber-700">{stats.guests}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Personal</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-700">{stats.personal}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Company</div>
|
|
||||||
<div className="text-xl font-semibold text-purple-700">{stats.company}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Active</div>
|
|
||||||
<div className="text-xl font-semibold text-green-700">{stats.active}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
|
||||||
<div className="text-xs text-gray-500">Pending</div>
|
|
||||||
<div className="text-xl font-semibold text-amber-700">{stats.pending}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-amber-100 hover:bg-amber-200 border border-amber-200 text-amber-800 text-base font-semibold px-5 py-3 shadow transition"
|
|
||||||
onClick={() => window.location.href = '/admin/user-verify'}
|
|
||||||
>{t('autofix.k2f78fabe')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
<div className="rounded-2xl border border-red-200 bg-red-50/90 px-4 py-3 text-sm text-red-800 backdrop-blur-sm">
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">{t('autofix.kbbefb159')}</p>
|
<p className="font-semibold">{t('autofix.kbbefb159')}</p>
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-red-700 mt-0.5">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchAllUsers}
|
onClick={() => {
|
||||||
|
void fetchAllUsers()
|
||||||
|
}}
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
>{t('autofix.k3b7dd87a')}</button>
|
>
|
||||||
</div>
|
{t('autofix.k3b7dd87a')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filter Card */}
|
<UserManagementFilters
|
||||||
<form
|
t={t}
|
||||||
onSubmit={applyFilter}
|
search={search}
|
||||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
setSearch={setSearch}
|
||||||
>
|
fType={fType}
|
||||||
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.kd1f35ccf')}</h2>
|
setFType={setFType}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
fStatus={fStatus}
|
||||||
{/* Search */}
|
setFStatus={setFStatus}
|
||||||
<div className="md:col-span-2">
|
fRole={fRole}
|
||||||
<label className="sr-only">Search</label>
|
setFRole={setFRole}
|
||||||
<div className="relative">
|
statusOptions={STATUS_OPTIONS}
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
typeOptions={TYPE_OPTIONS}
|
||||||
<input
|
roleOptions={ROLE_OPTIONS}
|
||||||
value={search}
|
onSubmit={applyFilters}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onExportCsv={exportCsv}
|
||||||
placeholder={t('autofix.k8b71f0c7')}
|
|
||||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Type */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fType}
|
|
||||||
onChange={e => setFType(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k10e2568f')}</option>
|
|
||||||
<option value="personal">Personal</option>
|
|
||||||
<option value="company">Company</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Status */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fStatus}
|
|
||||||
onChange={e => setFStatus(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k2e8f3110')}</option>
|
|
||||||
{STATUSES.map(s => <option key={s} value={s}>{s[0].toUpperCase()+s.slice(1)}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* Role */}
|
|
||||||
<div>
|
|
||||||
<select
|
|
||||||
value={fRole}
|
|
||||||
onChange={e => setFRole(e.target.value as any)}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k110bae43')}</option>
|
|
||||||
{ROLES.map(r => <option key={r} value={r}>{r[0].toUpperCase()+r.slice(1)}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={exportCsv}
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white hover:bg-gray-50 text-blue-900 text-sm font-semibold px-5 py-3 shadow transition"
|
|
||||||
title={t('autofix.k1387f81e')}
|
|
||||||
>{t('autofix.k1521a376')}</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 text-sm font-semibold px-5 py-3 shadow transition"
|
|
||||||
>
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
<UserManagementTable
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
t={t}
|
||||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
loading={loading}
|
||||||
<div className="text-lg font-semibold text-blue-900">{t('autofix.k10ccb626')}</div>
|
users={currentUsers}
|
||||||
<div className="text-xs text-gray-500">
|
totalFiltered={filteredUsers.length}
|
||||||
Showing {current.length} of {filtered.length} users
|
page={page}
|
||||||
</div>
|
totalPages={totalPages}
|
||||||
</div>
|
onPageChange={setPage}
|
||||||
<div className="overflow-x-auto">
|
onEdit={openUserDetail}
|
||||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
normalizeStatus={normalizeStatus}
|
||||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
/>
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left">User</th>
|
|
||||||
<th className="px-4 py-3 text-left">Type</th>
|
|
||||||
<th className="px-4 py-3 text-left">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left">Role</th>
|
|
||||||
<th className="px-4 py-3 text-left">Created</th>
|
|
||||||
<th className="px-4 py-3 text-left">{t('autofix.kb24782ec')}</th>
|
|
||||||
<th className="px-4 py-3 text-left">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
<span className="text-sm text-blue-900">{t('autofix.k7fa2c4af')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : current.map(u => {
|
|
||||||
const displayName = u.user_type === 'company'
|
|
||||||
? u.company_name || 'Unknown Company'
|
|
||||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
|
||||||
|
|
||||||
const initials = u.user_type === 'company'
|
|
||||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
|
||||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
|
||||||
|
|
||||||
// Use backend status directly for display to avoid desync
|
|
||||||
const allowedStatuses: UserStatus[] = ['active','pending','suspended','inactive','archived']
|
|
||||||
const userStatus: UserStatus = (allowedStatuses.includes(u.status as UserStatus) ? u.status : 'pending') as UserStatus
|
|
||||||
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
|
||||||
const lastLoginDate = u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={u.id} className="hover:bg-blue-50">
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900 leading-tight">
|
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-blue-700">
|
|
||||||
{u.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
|
||||||
<td className="px-4 py-4">{statusBadge(userStatus)}</td>
|
|
||||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-700 italic">
|
|
||||||
{lastLoginDate}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onEdit(u.id.toString())}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
|
||||||
>
|
|
||||||
<PencilSquareIcon className="h-4 w-4" /> Edit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{current.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">{t('autofix.k748bf541')}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
|
||||||
<div className="text-xs text-blue-700">
|
|
||||||
Page {page} of {totalPages} ({filtered.length} total users)
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
disabled={page===1}
|
|
||||||
onClick={() => setPage(p => Math.max(1,p-1))}
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>{t('autofix.kdb27a82d')}</button>
|
|
||||||
<button
|
|
||||||
disabled={page===totalPages}
|
|
||||||
onClick={() => setPage(p => Math.min(totalPages,p+1))}
|
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>{t('autofix.ka8ea17b8')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Detail Modal */}
|
|
||||||
<UserDetailModal
|
<UserDetailModal
|
||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={closeUserDetail}
|
||||||
setIsDetailModalOpen(false)
|
|
||||||
setSelectedUserId(null)
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
onUserUpdated={fetchAllUsers}
|
onUserUpdated={fetchAllUsers}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
export function UserVerifyInitialLoading({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 p-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="h-12 w-12 rounded-full border-2 border-slate-900 border-b-transparent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-slate-900">{t('autofix.k1e4d7a90')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyAccessDenied({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
|
<div className="mx-auto w-full max-w-xl rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] p-8 backdrop-blur">
|
||||||
|
<div className="text-center">
|
||||||
|
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||||
|
<h1 className="text-2xl font-bold text-red-600 mb-2 break-words">{t('autofix.k26fbc186')}</h1>
|
||||||
|
<p className="text-slate-600 break-words">{t('autofix.k661c032b')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
src/app/admin/user-verify/components/UserVerifyFilters.tsx
Normal file
151
src/app/admin/user-verify/components/UserVerifyFilters.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type {
|
||||||
|
StatusFilter,
|
||||||
|
UserRole,
|
||||||
|
UserType,
|
||||||
|
VerificationReadyFilter,
|
||||||
|
} from '../hooks/useUserVerifyPageState'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
search: string
|
||||||
|
setSearch: (value: string) => void
|
||||||
|
fType: 'all' | UserType
|
||||||
|
setFType: (value: 'all' | UserType) => void
|
||||||
|
fRole: 'all' | UserRole
|
||||||
|
setFRole: (value: 'all' | UserRole) => void
|
||||||
|
fReady: VerificationReadyFilter
|
||||||
|
setFReady: (value: VerificationReadyFilter) => void
|
||||||
|
fStatus: StatusFilter
|
||||||
|
setFStatus: (value: StatusFilter) => void
|
||||||
|
perPage: number
|
||||||
|
setPerPage: (value: number) => void
|
||||||
|
setPage: (page: number) => void
|
||||||
|
onSubmit: (event: React.FormEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserVerifyFilters({
|
||||||
|
t,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
fReady,
|
||||||
|
setFReady,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
setPage,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
className="rounded-[28px] border border-white/80 bg-white/85 px-6 sm:px-8 py-7 flex flex-col gap-6 mb-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{t('autofix.k85c66f50')}</h2>
|
||||||
|
<div className="flex flex-wrap gap-4 items-end">
|
||||||
|
<div className="min-w-[18rem] flex-[3_1_28rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k3f4f2b01')}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearch(event.target.value)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
placeholder={t('autofix.k8b71f0c7')}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white pl-10 pr-3 py-3 text-sm text-slate-900 focus:ring-2 focus:ring-slate-900/20 focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1.3_1_16rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k577a012c')}</label>
|
||||||
|
<select
|
||||||
|
value={fType}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFType(event.target.value as 'all' | UserType)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k10e2568f')}</option>
|
||||||
|
<option value="personal">{t('autofix.k8b2f1c77')}</option>
|
||||||
|
<option value="company">{t('autofix.k6c3d4e55')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1.1_1_16rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k8f1a2c34')}</label>
|
||||||
|
<select
|
||||||
|
value={fRole}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFRole(event.target.value as 'all' | UserRole)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k110bae43')}</option>
|
||||||
|
<option value="user">{t('autofix.k9d0a7b42')}</option>
|
||||||
|
<option value="admin">{t('autofix.k2a6c8d90')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[18rem] flex-[2_1_22rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k0efd830c')}</label>
|
||||||
|
<select
|
||||||
|
value={fReady}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFReady(event.target.value as VerificationReadyFilter)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k7ab45054')}</option>
|
||||||
|
<option value="ready">{t('autofix.kf27e4502')}</option>
|
||||||
|
<option value="not_ready">{t('autofix.k4e0c889b')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[13rem] flex-[1_1_14rem]">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.k7e2d9a10')}</label>
|
||||||
|
<select
|
||||||
|
value={fStatus}
|
||||||
|
onChange={(event) => {
|
||||||
|
setFStatus(event.target.value as StatusFilter)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
<option value="all">{t('autofix.k0f1fc266')}</option>
|
||||||
|
<option value="pending">{t('autofix.k1b3d5f78')}</option>
|
||||||
|
<option value="active">{t('autofix.k4c7e9a21')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[11rem] basis-full w-full">
|
||||||
|
<label className="block text-xs font-semibold uppercase tracking-wide text-slate-600 mb-1 whitespace-normal break-words">{t('autofix.kd2e35b08')}</label>
|
||||||
|
<select
|
||||||
|
value={perPage}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPerPage(parseInt(event.target.value, 10))
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className="w-full min-w-0 rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900"
|
||||||
|
>
|
||||||
|
{[5, 10, 15, 20].map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type ErrorCardProps = {
|
||||||
|
t: Translator
|
||||||
|
error: string
|
||||||
|
onRetry: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyHeader({ t }: { t: Translator }) {
|
||||||
|
return (
|
||||||
|
<header className="rounded-[28px] border border-white/80 bg-white/85 py-8 px-6 sm:px-8 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] backdrop-blur-md mb-6">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
{t('autofix.k5b2c8d67')}
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-3xl sm:text-4xl font-extrabold text-slate-900 tracking-tight break-words">{t('autofix.kccde6d86')}</h1>
|
||||||
|
<p className="text-base sm:text-lg text-slate-600 mt-2 break-words">{t('autofix.k5614c806')}</p>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserVerifyErrorCard({ t, error, onRetry }: ErrorCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-red-300 bg-red-50/90 text-red-700 px-6 py-5 flex gap-3 items-start mb-6 shadow-[0_16px_40px_-28px_rgba(185,28,28,0.4)] backdrop-blur-sm">
|
||||||
|
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{t('autofix.k62d12fab')}</p>
|
||||||
|
<p className="text-sm text-red-600 break-words">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 text-sm underline hover:no-underline"
|
||||||
|
>
|
||||||
|
{t('autofix.k3b7dd87a')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
src/app/admin/user-verify/components/UserVerifyUsersTable.tsx
Normal file
187
src/app/admin/user-verify/components/UserVerifyUsersTable.tsx
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { EyeIcon } from '@heroicons/react/24/outline'
|
||||||
|
import type { PendingUser } from '../../../utils/api'
|
||||||
|
|
||||||
|
type Translator = (key: string) => string
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
t: Translator
|
||||||
|
loading: boolean
|
||||||
|
current: PendingUser[]
|
||||||
|
filteredLength: number
|
||||||
|
page: number
|
||||||
|
totalPages: number
|
||||||
|
onPageChange: (next: number) => void
|
||||||
|
onViewUser: (id: string | number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMessage(template: string, values: Record<string, string | number>) {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, token: string) => String(values[token] ?? ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeBase = 'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium'
|
||||||
|
|
||||||
|
const badge = (text: string, color: string) => (
|
||||||
|
<span className={`${badgeBase} ${color}`}>{text}</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeBadge = (type: 'personal' | 'company', t: Translator) =>
|
||||||
|
type === 'personal'
|
||||||
|
? badge(t('autofix.k8b2f1c77'), 'bg-blue-100 text-blue-700')
|
||||||
|
: badge(t('autofix.k6c3d4e55'), 'bg-purple-100 text-purple-700')
|
||||||
|
|
||||||
|
const roleBadge = (role: 'user' | 'admin', t: Translator) =>
|
||||||
|
role === 'admin'
|
||||||
|
? badge(t('autofix.k2a6c8d90'), 'bg-indigo-100 text-indigo-700')
|
||||||
|
: badge(t('autofix.k9d0a7b42'), 'bg-gray-100 text-gray-700')
|
||||||
|
|
||||||
|
const statusBadge = (status: PendingUser['status'], t: Translator) =>
|
||||||
|
status === 'pending'
|
||||||
|
? badge(t('autofix.k1b3d5f78'), 'bg-amber-100 text-amber-700')
|
||||||
|
: badge(t('autofix.k4c7e9a21'), 'bg-green-100 text-green-700')
|
||||||
|
|
||||||
|
const verificationStatusBadge = (user: PendingUser, t: Translator) => {
|
||||||
|
const completedSteps = [
|
||||||
|
user.email_verified === 1,
|
||||||
|
user.profile_completed === 1,
|
||||||
|
user.documents_uploaded === 1,
|
||||||
|
user.contract_signed === 1,
|
||||||
|
].filter(Boolean).length
|
||||||
|
|
||||||
|
const totalSteps = 4
|
||||||
|
if (completedSteps === totalSteps) {
|
||||||
|
return badge(t('autofix.k5d8a1c63'), 'bg-green-100 text-green-700')
|
||||||
|
}
|
||||||
|
|
||||||
|
return badge(`${completedSteps}/${totalSteps} ${t('autofix.k7a4e2b19')}`, 'bg-gray-100 text-gray-700')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserVerifyUsersTable({
|
||||||
|
t,
|
||||||
|
loading,
|
||||||
|
current,
|
||||||
|
filteredLength,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
onViewUser,
|
||||||
|
}: Props) {
|
||||||
|
const summaryText = formatMessage(t('autofix.k1f8c4a52'), {
|
||||||
|
current: current.length,
|
||||||
|
total: filteredLength,
|
||||||
|
})
|
||||||
|
|
||||||
|
const paginationText = formatMessage(t('autofix.k9b5d2e70'), {
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
total: filteredLength,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/80 bg-white/85 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.32)] overflow-hidden mb-8 backdrop-blur-md">
|
||||||
|
<div className="px-6 sm:px-8 py-6 border-b border-slate-100 flex items-center justify-between gap-3">
|
||||||
|
<div className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k0da2c941')}</div>
|
||||||
|
<div className="text-xs text-slate-500 break-words">{summaryText}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-slate-100 text-sm">
|
||||||
|
<thead className="bg-slate-50 text-slate-900 font-medium">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k7c1e5b40')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k8d4a2f16')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k3e9b6c12')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k7e2d9a10')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k8f1a2c34')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k9a5c1e68')}</th>
|
||||||
|
<th className="px-4 py-3 text-left">{t('autofix.k2f6d9a33')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="h-4 w-4 rounded-full border-2 border-slate-900 border-b-transparent animate-spin" />
|
||||||
|
<span className="text-sm text-slate-900">{t('autofix.k7fa2c4af')}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
current.map((user) => {
|
||||||
|
const displayName =
|
||||||
|
user.user_type === 'company'
|
||||||
|
? user.company_name || t('autofix.k2d7f4a81')
|
||||||
|
: `${user.first_name || t('autofix.k9f3a1e74')} ${user.last_name || t('autofix.k9d0a7b42')}`
|
||||||
|
|
||||||
|
const initials =
|
||||||
|
user.user_type === 'company'
|
||||||
|
? (user.company_name?.[0] || 'C').toUpperCase()
|
||||||
|
: `${user.first_name?.[0] || 'U'}${user.last_name?.[0] || 'U'}`.toUpperCase()
|
||||||
|
|
||||||
|
const createdDate = new Date(user.created_at).toLocaleDateString()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50/80 transition-colors">
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-slate-900 to-slate-700 text-white text-xs font-semibold shadow shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-medium text-slate-900 leading-tight break-words">{displayName}</div>
|
||||||
|
<div className="text-[11px] text-slate-600 break-all">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-4">{typeBadge(user.user_type, t)}</td>
|
||||||
|
<td className="px-4 py-4">{verificationStatusBadge(user, t)}</td>
|
||||||
|
<td className="px-4 py-4">{statusBadge(user.status, t)}</td>
|
||||||
|
<td className="px-4 py-4">{roleBadge(user.role, t)}</td>
|
||||||
|
<td className="px-4 py-4 text-slate-900">{createdDate}</td>
|
||||||
|
<td className="px-4 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onViewUser(user.id)}
|
||||||
|
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white hover:bg-slate-100 text-slate-900 px-3 py-2 text-xs font-medium transition"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-4 w-4" /> {t('autofix.k1c7b4e52')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{current.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-10 text-center text-sm text-slate-700">
|
||||||
|
{t('autofix.kb4aba3dc')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-6 sm:px-8 py-6 bg-slate-50 border-t border-slate-100">
|
||||||
|
<div className="text-xs text-slate-700 break-words">{paginationText}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.kdb27a82d')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => onPageChange(Math.min(totalPages, page + 1))}
|
||||||
|
className="px-4 py-2 text-xs font-medium rounded-xl border border-slate-300 bg-white hover:bg-slate-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{t('autofix.ka8ea17b8')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
95
src/app/admin/user-verify/hooks/useUserVerifyPageState.ts
Normal file
95
src/app/admin/user-verify/hooks/useUserVerifyPageState.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||||
|
import type { PendingUser } from '../../../utils/api'
|
||||||
|
|
||||||
|
export type UserType = 'personal' | 'company'
|
||||||
|
export type UserRole = 'user' | 'admin'
|
||||||
|
export type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
||||||
|
export type StatusFilter = 'all' | 'pending' | 'active'
|
||||||
|
|
||||||
|
export function useUserVerifyPageState(pendingUsers: PendingUser[]) {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [fType, setFType] = useState<'all' | UserType>('all')
|
||||||
|
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
||||||
|
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
||||||
|
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
||||||
|
const [perPage, setPerPage] = useState(10)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||||
|
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return pendingUsers.filter((u) => {
|
||||||
|
const firstName = u.first_name || ''
|
||||||
|
const lastName = u.last_name || ''
|
||||||
|
const companyName = u.company_name || ''
|
||||||
|
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
||||||
|
const isReadyToVerify =
|
||||||
|
u.email_verified === 1 &&
|
||||||
|
u.profile_completed === 1 &&
|
||||||
|
u.documents_uploaded === 1 &&
|
||||||
|
u.contract_signed === 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
(fType === 'all' || u.user_type === fType) &&
|
||||||
|
(fRole === 'all' || u.role === fRole) &&
|
||||||
|
(fStatus === 'all' || u.status === fStatus) &&
|
||||||
|
(fReady === 'all' ||
|
||||||
|
(fReady === 'ready' && isReadyToVerify) ||
|
||||||
|
(fReady === 'not_ready' && !isReadyToVerify)) &&
|
||||||
|
(!search.trim() ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
fullName.toLowerCase().includes(search.toLowerCase()))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
||||||
|
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
||||||
|
|
||||||
|
const applyFilters = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openUserDetail = (id: string | number) => {
|
||||||
|
setSelectedUserId(String(id))
|
||||||
|
setIsDetailModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeUserDetail = () => {
|
||||||
|
setIsDetailModalOpen(false)
|
||||||
|
setSelectedUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isClient,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
fType,
|
||||||
|
setFType,
|
||||||
|
fRole,
|
||||||
|
setFRole,
|
||||||
|
fReady,
|
||||||
|
setFReady,
|
||||||
|
fStatus,
|
||||||
|
setFStatus,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
filtered,
|
||||||
|
current,
|
||||||
|
totalPages,
|
||||||
|
applyFilters,
|
||||||
|
isDetailModalOpen,
|
||||||
|
selectedUserId,
|
||||||
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,21 +3,14 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
|
||||||
import PageLayout from '../../components/PageLayout'
|
import PageLayout from '../../components/PageLayout'
|
||||||
import UserDetailModal from '../../components/UserDetailModal'
|
import UserDetailModal from '../../components/UserDetailModal'
|
||||||
import {
|
|
||||||
MagnifyingGlassIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
EyeIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
import { useAdminUsers } from '../../hooks/useAdminUsers'
|
||||||
import { PendingUser } from '../../utils/api'
|
import { UserVerifyAccessDenied, UserVerifyInitialLoading } from './components/UserVerifyAccessStates'
|
||||||
|
import { UserVerifyErrorCard, UserVerifyHeader } from './components/UserVerifyHeaderAndError'
|
||||||
type UserType = 'personal' | 'company'
|
import UserVerifyFilters from './components/UserVerifyFilters'
|
||||||
type UserRole = 'user' | 'admin'
|
import UserVerifyUsersTable from './components/UserVerifyUsersTable'
|
||||||
type VerificationReadyFilter = 'all' | 'ready' | 'not_ready'
|
import { useUserVerifyPageState } from './hooks/useUserVerifyPageState'
|
||||||
type StatusFilter = 'all' | 'pending' | 'active'
|
|
||||||
|
|
||||||
export default function AdminUserVerifyPage() {
|
export default function AdminUserVerifyPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -26,110 +19,39 @@ export default function AdminUserVerifyPage() {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
fetchPendingUsers
|
fetchPendingUsers,
|
||||||
} = useAdminUsers()
|
} = useAdminUsers()
|
||||||
const [isClient, setIsClient] = useState(false)
|
const {
|
||||||
|
isClient,
|
||||||
// Handle client-side mounting
|
search,
|
||||||
useEffect(() => {
|
setSearch,
|
||||||
setIsClient(true)
|
fType,
|
||||||
}, [])
|
setFType,
|
||||||
const [search, setSearch] = useState('')
|
fRole,
|
||||||
const [fType, setFType] = useState<'all' | UserType>('all')
|
setFRole,
|
||||||
const [fRole, setFRole] = useState<'all' | UserRole>('all')
|
fReady,
|
||||||
const [fReady, setFReady] = useState<VerificationReadyFilter>('all')
|
setFReady,
|
||||||
const [fStatus, setFStatus] = useState<StatusFilter>('all')
|
fStatus,
|
||||||
const [perPage, setPerPage] = useState(10)
|
setFStatus,
|
||||||
const [page, setPage] = useState(1)
|
perPage,
|
||||||
|
setPerPage,
|
||||||
// All computations must be after hooks but before conditional returns
|
page,
|
||||||
const filtered = useMemo(() => {
|
setPage,
|
||||||
return pendingUsers.filter(u => {
|
filtered,
|
||||||
const firstName = u.first_name || ''
|
current,
|
||||||
const lastName = u.last_name || ''
|
totalPages,
|
||||||
const companyName = u.company_name || ''
|
applyFilters,
|
||||||
const fullName = u.user_type === 'company' ? companyName : `${firstName} ${lastName}`
|
isDetailModalOpen,
|
||||||
const isReadyToVerify = u.email_verified === 1 && u.profile_completed === 1 &&
|
selectedUserId,
|
||||||
u.documents_uploaded === 1 && u.contract_signed === 1
|
openUserDetail,
|
||||||
|
closeUserDetail,
|
||||||
return (
|
} = useUserVerifyPageState(pendingUsers)
|
||||||
(fType === 'all' || u.user_type === fType) &&
|
|
||||||
(fRole === 'all' || u.role === fRole) &&
|
|
||||||
(fStatus === 'all' || u.status === fStatus) &&
|
|
||||||
(
|
|
||||||
fReady === 'all' ||
|
|
||||||
(fReady === 'ready' && isReadyToVerify) ||
|
|
||||||
(fReady === 'not_ready' && !isReadyToVerify)
|
|
||||||
) &&
|
|
||||||
(
|
|
||||||
!search.trim() ||
|
|
||||||
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
fullName.toLowerCase().includes(search.toLowerCase())
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}, [pendingUsers, search, fType, fRole, fReady, fStatus])
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / perPage))
|
|
||||||
const current = filtered.slice((page - 1) * perPage, page * perPage)
|
|
||||||
|
|
||||||
// Modal state
|
|
||||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const applyFilters = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setPage(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const badge = (text: string, color: string) =>
|
|
||||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${color}`}>
|
|
||||||
{text}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
const typeBadge = (t: UserType) =>
|
|
||||||
t === 'personal'
|
|
||||||
? badge('Personal', 'bg-blue-100 text-blue-700')
|
|
||||||
: badge('Company', 'bg-purple-100 text-purple-700')
|
|
||||||
|
|
||||||
const roleBadge = (r: UserRole) =>
|
|
||||||
r === 'admin'
|
|
||||||
? badge('Admin', 'bg-indigo-100 text-indigo-700')
|
|
||||||
: badge('User', 'bg-gray-100 text-gray-700')
|
|
||||||
|
|
||||||
const statusBadge = (s: PendingUser['status']) => {
|
|
||||||
if (s === 'pending') return badge('Pending', 'bg-amber-100 text-amber-700')
|
|
||||||
return badge('Active', 'bg-green-100 text-green-700')
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationStatusBadge = (user: PendingUser) => {
|
|
||||||
const steps = [
|
|
||||||
{ name: 'Email', completed: user.email_verified === 1 },
|
|
||||||
{ name: 'Profile', completed: user.profile_completed === 1 },
|
|
||||||
{ name: 'Documents', completed: user.documents_uploaded === 1 },
|
|
||||||
{ name: 'Contract', completed: user.contract_signed === 1 }
|
|
||||||
]
|
|
||||||
|
|
||||||
const completedSteps = steps.filter(s => s.completed).length
|
|
||||||
const totalSteps = steps.length
|
|
||||||
|
|
||||||
if (completedSteps === totalSteps) {
|
|
||||||
return badge('Ready to Verify', 'bg-green-100 text-green-700')
|
|
||||||
} else {
|
|
||||||
return badge(`${completedSteps}/${totalSteps} Steps`, 'bg-gray-100 text-gray-700')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading during SSR/initial client render
|
// Show loading during SSR/initial client render
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserVerifyInitialLoading t={t} />
|
||||||
<div className="text-center">
|
|
||||||
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
|
|
||||||
<p className="text-blue-900">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -138,242 +60,60 @@ export default function AdminUserVerifyPage() {
|
|||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<UserVerifyAccessDenied t={t} />
|
||||||
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<ExclamationTriangleIcon className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('autofix.k26fbc186')}</h1>
|
|
||||||
<p className="text-gray-600">{t('autofix.k661c032b')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.12),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.12),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#eef2ff_100%)]">
|
||||||
<main className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1820px] mx-auto px-2 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Header */}
|
<UserVerifyHeader t={t} />
|
||||||
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('autofix.kccde6d86')}</h1>
|
|
||||||
<p className="text-lg text-blue-700 mt-2">{t('autofix.k5614c806')}</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-xl border border-red-300 bg-red-50 text-red-700 px-6 py-5 flex gap-3 items-start mb-8 shadow">
|
<UserVerifyErrorCard
|
||||||
<ExclamationTriangleIcon className="h-5 w-5 flex-shrink-0 text-red-500 mt-0.5" />
|
t={t}
|
||||||
<div>
|
error={error}
|
||||||
<p className="font-semibold">{t('autofix.k62d12fab')}</p>
|
onRetry={fetchPendingUsers}
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={fetchPendingUsers}
|
|
||||||
className="mt-2 text-sm underline hover:no-underline"
|
|
||||||
>{t('autofix.k3b7dd87a')}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filter Card */}
|
|
||||||
<form
|
|
||||||
onSubmit={applyFilters}
|
|
||||||
className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 px-8 py-8 flex flex-col gap-6 mb-8"
|
|
||||||
>
|
|
||||||
<h2 className="text-lg font-semibold text-blue-900">{t('autofix.k85c66f50')}</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-7 gap-4">
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Search</label>
|
|
||||||
<div className="relative">
|
|
||||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-blue-300" />
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
placeholder={t('autofix.k8b71f0c7')}
|
|
||||||
className="w-full rounded-lg border border-gray-300 pl-10 pr-3 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.k577a012c')}</label>
|
|
||||||
<select
|
|
||||||
value={fType}
|
|
||||||
onChange={e => { setFType(e.target.value as any); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k10e2568f')}</option>
|
|
||||||
<option value="personal">Personal</option>
|
|
||||||
<option value="company">Company</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Role</label>
|
|
||||||
<select
|
|
||||||
value={fRole}
|
|
||||||
onChange={e => { setFRole(e.target.value as any); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k110bae43')}</option>
|
|
||||||
<option value="user">User</option>
|
|
||||||
<option value="admin">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.k0efd830c')}</label>
|
|
||||||
<select
|
|
||||||
value={fReady}
|
|
||||||
onChange={e => { setFReady(e.target.value as VerificationReadyFilter); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k7ab45054')}</option>
|
|
||||||
<option value="ready">{t('autofix.kf27e4502')}</option>
|
|
||||||
<option value="not_ready">{t('autofix.k4e0c889b')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">Status</label>
|
|
||||||
<select
|
|
||||||
value={fStatus}
|
|
||||||
onChange={e => { setFStatus(e.target.value as StatusFilter); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
<option value="all">{t('autofix.k0f1fc266')}</option>
|
|
||||||
<option value="pending">Pending</option>
|
|
||||||
<option value="active">Active</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-semibold text-blue-900 mb-1">{t('autofix.kd2e35b08')}</label>
|
|
||||||
<select
|
|
||||||
value={perPage}
|
|
||||||
onChange={e => { setPerPage(parseInt(e.target.value, 10)); setPage(1) }}
|
|
||||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm text-blue-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent shadow"
|
|
||||||
>
|
|
||||||
{[5, 10, 15, 20].map(n => <option key={n} value={n}>{n}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Pending Users Table */}
|
|
||||||
<div className="bg-white rounded-2xl shadow-lg ring-1 ring-gray-100 overflow-hidden mb-8">
|
|
||||||
<div className="px-8 py-6 border-b border-gray-100 flex items-center justify-between">
|
|
||||||
<div className="text-lg font-semibold text-blue-900">{t('autofix.k0da2c941')}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Showing {current.length} of {filtered.length} users
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-100 text-sm">
|
|
||||||
<thead className="bg-blue-50 text-blue-900 font-medium">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left">User</th>
|
|
||||||
<th className="px-4 py-3 text-left">Type</th>
|
|
||||||
<th className="px-4 py-3 text-left">Progress</th>
|
|
||||||
<th className="px-4 py-3 text-left">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left">Role</th>
|
|
||||||
<th className="px-4 py-3 text-left">Created</th>
|
|
||||||
<th className="px-4 py-3 text-left">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<div className="h-4 w-4 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
|
||||||
<span className="text-sm text-blue-900">{t('autofix.k7fa2c4af')}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : current.map(u => {
|
|
||||||
const displayName = u.user_type === 'company'
|
|
||||||
? u.company_name || 'Unknown Company'
|
|
||||||
: `${u.first_name || 'Unknown'} ${u.last_name || 'User'}`
|
|
||||||
|
|
||||||
const initials = u.user_type === 'company'
|
|
||||||
? (u.company_name?.[0] || 'C').toUpperCase()
|
|
||||||
: `${u.first_name?.[0] || 'U'}${u.last_name?.[0] || 'U'}`.toUpperCase()
|
|
||||||
|
|
||||||
const createdDate = new Date(u.created_at).toLocaleDateString()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={u.id} className="hover:bg-blue-50">
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-9 w-9 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-900 to-blue-700 text-white text-xs font-semibold shadow">
|
|
||||||
{initials}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-blue-900 leading-tight">
|
|
||||||
{displayName}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-blue-700">{u.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4">{typeBadge(u.user_type)}</td>
|
|
||||||
<td className="px-4 py-4">{verificationStatusBadge(u)}</td>
|
|
||||||
<td className="px-4 py-4">{statusBadge(u.status)}</td>
|
|
||||||
<td className="px-4 py-4">{roleBadge(u.role)}</td>
|
|
||||||
<td className="px-4 py-4 text-blue-900">{createdDate}</td>
|
|
||||||
<td className="px-4 py-4">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedUserId(u.id.toString())
|
|
||||||
setIsDetailModalOpen(true)
|
|
||||||
}}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 hover:bg-blue-100 text-blue-900 px-3 py-2 text-xs font-medium transition"
|
|
||||||
>
|
|
||||||
<EyeIcon className="h-4 w-4" /> View
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{current.length === 0 && !loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-4 py-10 text-center text-sm text-blue-700">{t('autofix.kb4aba3dc')}</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
)}
|
||||||
</tbody>
|
|
||||||
</table>
|
<UserVerifyFilters
|
||||||
</div>
|
t={t}
|
||||||
{/* Pagination */}
|
search={search}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 px-8 py-6 bg-blue-50 border-t border-blue-100">
|
setSearch={setSearch}
|
||||||
<div className="text-xs text-blue-700">
|
fType={fType}
|
||||||
Page {page} of {totalPages} ({filtered.length} pending users)
|
setFType={setFType}
|
||||||
</div>
|
fRole={fRole}
|
||||||
<div className="flex gap-2">
|
setFRole={setFRole}
|
||||||
<button
|
fReady={fReady}
|
||||||
disabled={page === 1}
|
setFReady={setFReady}
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
fStatus={fStatus}
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
setFStatus={setFStatus}
|
||||||
>{t('autofix.kdb27a82d')}</button>
|
perPage={perPage}
|
||||||
<button
|
setPerPage={setPerPage}
|
||||||
disabled={page === totalPages}
|
setPage={setPage}
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
onSubmit={applyFilters}
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg border border-gray-300 bg-white hover:bg-blue-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
/>
|
||||||
>{t('autofix.ka8ea17b8')}</button>
|
|
||||||
</div>
|
<UserVerifyUsersTable
|
||||||
</div>
|
t={t}
|
||||||
</div>
|
loading={loading}
|
||||||
|
current={current}
|
||||||
|
filteredLength={filtered.length}
|
||||||
|
page={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onViewUser={openUserDetail}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Detail Modal */}
|
{/* User Detail Modal */}
|
||||||
<UserDetailModal
|
<UserDetailModal
|
||||||
isOpen={isDetailModalOpen}
|
isOpen={isDetailModalOpen}
|
||||||
onClose={() => {
|
onClose={closeUserDetail}
|
||||||
setIsDetailModalOpen(false)
|
|
||||||
setSelectedUserId(null)
|
|
||||||
}}
|
|
||||||
userId={selectedUserId}
|
userId={selectedUserId}
|
||||||
onUserUpdated={() => {
|
onUserUpdated={() => {
|
||||||
fetchPendingUsers()
|
fetchPendingUsers()
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { promises as fs } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { en } from '@/app/i18n/translations/en';
|
import { en } from '@/app/i18n/translations/en';
|
||||||
|
import { de } from '@/app/i18n/translations/de';
|
||||||
import { flattenObject } from '@/app/i18n/dynamicTranslations';
|
import { flattenObject } from '@/app/i18n/dynamicTranslations';
|
||||||
import { requireAdminSession } from '../../_utils/backendAuth';
|
import { requireAdminSession } from '../../_utils/backendAuth';
|
||||||
|
|
||||||
@ -73,6 +74,10 @@ interface AutoFixOptions {
|
|||||||
forceConvertToClient?: boolean;
|
forceConvertToClient?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AddMissingKeysResult {
|
||||||
|
createdKeys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const;
|
const TRANSLATABLE_ATTRIBUTES = ['placeholder', 'title', 'alt', 'aria-label'] as const;
|
||||||
const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/;
|
const USE_CLIENT_PREFIX_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*['\"]use client['\"]\s*;?\s*/;
|
||||||
const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/;
|
const LEADING_PREAMBLE_REGEX = /^\uFEFF?\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*/;
|
||||||
@ -190,6 +195,17 @@ function escapeForTsString(value: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeForQuotedString(value: string, quote: '\'' | '"'): string {
|
||||||
|
const normalized = value
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\r?\n/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return quote === '\''
|
||||||
|
? normalized.replace(/'/g, "\\'")
|
||||||
|
: normalized.replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
function hashText(input: string): string {
|
function hashText(input: string): string {
|
||||||
let hash = 5381;
|
let hash = 5381;
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
for (let i = 0; i < input.length; i += 1) {
|
||||||
@ -492,39 +508,61 @@ function replaceJsxTextNodes(content: string): { content: string; replacements:
|
|||||||
function upsertAutofixNamespace(content: string, entries: Map<string, string>): string {
|
function upsertAutofixNamespace(content: string, entries: Map<string, string>): string {
|
||||||
if (entries.size === 0) return content;
|
if (entries.size === 0) return content;
|
||||||
|
|
||||||
|
const autofixBlockRegex = /\n(\s*)((?:"autofix"|autofix)\s*:\s*\{)([\s\S]*?)\n\1\},/m;
|
||||||
|
const match = content.match(autofixBlockRegex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const indent = match[1] ?? ' ';
|
||||||
|
const blockHeader = match[2] ?? 'autofix: {';
|
||||||
|
const blockBody = match[3] ?? '';
|
||||||
|
const existing = new Set<string>();
|
||||||
|
for (const m of blockBody.matchAll(/\n\s{4}(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:\s*['"]/g)) {
|
||||||
|
const keyName = m[1] || m[2];
|
||||||
|
if (keyName) {
|
||||||
|
existing.add(`autofix.${keyName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useQuotedKeys = /"[A-Za-z0-9_]+"\s*:/.test(blockBody) || /"autofix"\s*:/.test(blockHeader);
|
||||||
|
const valueQuote: '\'' | '"' = /:\s*"/.test(blockBody) ? '"' : '\'';
|
||||||
|
|
||||||
const entryLines = Array.from(entries.entries())
|
const entryLines = Array.from(entries.entries())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
const shortKey = key.replace(/^autofix\./, '');
|
const shortKey = key.replace(/^autofix\./, '');
|
||||||
return ` ${shortKey}: '${escapeForTsString(value)}',`;
|
const renderedKey = useQuotedKeys ? `"${shortKey}"` : shortKey;
|
||||||
|
return `${indent} ${renderedKey}: ${valueQuote}${escapeForQuotedString(value, valueQuote)}${valueQuote},`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const autofixBlockRegex = /\n\s{2}autofix:\s*\{([\s\S]*?)\n\s{2}\},/m;
|
|
||||||
const match = content.match(autofixBlockRegex);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const blockBody = match[1] ?? '';
|
|
||||||
const existing = new Set<string>();
|
|
||||||
for (const m of blockBody.matchAll(/\n\s{4}([A-Za-z0-9_]+):\s*'/g)) {
|
|
||||||
existing.add(`autofix.${m[1]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const missing = entryLines.filter((line) => {
|
const missing = entryLines.filter((line) => {
|
||||||
const m = line.match(/^\s{4}([A-Za-z0-9_]+):/);
|
const m = line.match(/^\s+(?:"([A-Za-z0-9_]+)"|([A-Za-z0-9_]+))\s*:/);
|
||||||
if (!m) return false;
|
if (!m) return false;
|
||||||
return !existing.has(`autofix.${m[1]}`);
|
const keyName = m[1] || m[2];
|
||||||
|
return keyName ? !existing.has(`autofix.${keyName}`) : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (missing.length === 0) return content;
|
if (missing.length === 0) return content;
|
||||||
|
|
||||||
const replacement = `\n autofix: {${blockBody}\n${missing.join('\n')}\n },`;
|
const replacement = `\n${indent}${blockHeader}${blockBody}\n${missing.join('\n')}\n${indent}},`;
|
||||||
return content.replace(autofixBlockRegex, replacement);
|
return content.replace(autofixBlockRegex, replacement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastsComment = /\n\s{2}\/\/\s*─+\s*Notifications\s*\/\s*Toasts[\s\S]*?\n\s{2}toasts:/m;
|
const toastsKeyMatch = content.match(/\n(\s*)((?:"toasts"|toasts)\s*:)/m);
|
||||||
if (toastsComment.test(content)) {
|
if (toastsKeyMatch) {
|
||||||
const autofixBlock = `\n autofix: {\n${entryLines.join('\n')}\n },\n`;
|
const indent = toastsKeyMatch[1] ?? ' ';
|
||||||
return content.replace(toastsComment, `${autofixBlock}\n // ─── Notifications / Toasts ────────────────────────────\n toasts:`);
|
const useQuotedKeys = /"toasts"\s*:/.test(toastsKeyMatch[2] ?? '');
|
||||||
|
const valueQuote: '\'' | '"' = /"[A-Za-z0-9_]+"\s*:\s*"/.test(content) ? '"' : '\'';
|
||||||
|
const entryLines = Array.from(entries.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const shortKey = key.replace(/^autofix\./, '');
|
||||||
|
const renderedKey = useQuotedKeys ? `"${shortKey}"` : shortKey;
|
||||||
|
return `${indent} ${renderedKey}: ${valueQuote}${escapeForQuotedString(value, valueQuote)}${valueQuote},`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const autofixHeader = useQuotedKeys ? '"autofix": {' : 'autofix: {';
|
||||||
|
const autofixBlock = `\n${indent}${autofixHeader}\n${entryLines.join('\n')}\n${indent}},\n`;
|
||||||
|
return content.replace(/\n\s*(?:"toasts"|toasts)\s*:/m, `${autofixBlock}\n${indent}${toastsKeyMatch[2]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
@ -720,6 +758,59 @@ async function runAutoFix(options: AutoFixOptions = {}): Promise<AutoFixResult>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addMissingAutofixKeysToTranslations(missingKeys: string[]): Promise<AddMissingKeysResult> {
|
||||||
|
const autofixMissing = missingKeys
|
||||||
|
.map((key) => key.trim())
|
||||||
|
.filter((key) => /^autofix\.[A-Za-z0-9_]+$/.test(key));
|
||||||
|
|
||||||
|
if (autofixMissing.length === 0) {
|
||||||
|
return { createdKeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enFlat = flattenObject(en as Record<string, unknown>);
|
||||||
|
const deFlat = flattenObject(de as Record<string, unknown>);
|
||||||
|
const entriesForEn = new Map<string, string>();
|
||||||
|
const entriesForDe = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const key of autofixMissing) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(enFlat, key)) continue;
|
||||||
|
|
||||||
|
const deValue = typeof deFlat[key] === 'string' ? String(deFlat[key]) : '';
|
||||||
|
const fallbackValue = key;
|
||||||
|
|
||||||
|
entriesForEn.set(key, deValue || fallbackValue);
|
||||||
|
entriesForDe.set(key, deValue || fallbackValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entriesForEn.size === 0) {
|
||||||
|
return { createdKeys: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'en.ts');
|
||||||
|
const dePath = path.join(process.cwd(), 'src', 'app', 'i18n', 'translations', 'de.ts');
|
||||||
|
const typesPath = path.join(process.cwd(), 'src', 'app', 'i18n', 'types.ts');
|
||||||
|
|
||||||
|
const [enRaw, deRaw, typesRaw] = await Promise.all([
|
||||||
|
fs.readFile(enPath, 'utf8'),
|
||||||
|
fs.readFile(dePath, 'utf8'),
|
||||||
|
fs.readFile(typesPath, 'utf8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nextEn = upsertAutofixNamespace(enRaw, entriesForEn);
|
||||||
|
const nextDe = upsertAutofixNamespace(deRaw, entriesForDe);
|
||||||
|
const nextTypes = upsertAutofixType(typesRaw);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
nextEn !== enRaw ? fs.writeFile(enPath, nextEn, 'utf8') : Promise.resolve(),
|
||||||
|
nextDe !== deRaw ? fs.writeFile(dePath, nextDe, 'utf8') : Promise.resolve(),
|
||||||
|
nextTypes !== typesRaw ? fs.writeFile(typesPath, nextTypes, 'utf8') : Promise.resolve(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createdKeys: Array.from(entriesForEn.keys()).sort(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toRelativeWorkspacePath(absPath: string): string {
|
function toRelativeWorkspacePath(absPath: string): string {
|
||||||
const rel = path.relative(process.cwd(), absPath);
|
const rel = path.relative(process.cwd(), absPath);
|
||||||
return rel.split(path.sep).join('/');
|
return rel.split(path.sep).join('/');
|
||||||
@ -832,8 +923,12 @@ export async function POST(request: Request) {
|
|||||||
try {
|
try {
|
||||||
let targetFilesSet: Set<string> | undefined;
|
let targetFilesSet: Set<string> | undefined;
|
||||||
let forceConvertToClient = false;
|
let forceConvertToClient = false;
|
||||||
|
let mode: 'autofix' | 'add-missing-keys' = 'autofix';
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
if (body?.mode === 'add-missing-keys') {
|
||||||
|
mode = 'add-missing-keys';
|
||||||
|
}
|
||||||
const raw = Array.isArray(body?.targetFiles) ? body.targetFiles : [];
|
const raw = Array.isArray(body?.targetFiles) ? body.targetFiles : [];
|
||||||
forceConvertToClient = Boolean(body?.forceConvertToClient);
|
forceConvertToClient = Boolean(body?.forceConvertToClient);
|
||||||
const cleaned = raw
|
const cleaned = raw
|
||||||
@ -847,6 +942,25 @@ export async function POST(request: Request) {
|
|||||||
// allow empty body (fix all eligible files)
|
// allow empty body (fix all eligible files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'add-missing-keys') {
|
||||||
|
const preScan = await runWorkspaceScan();
|
||||||
|
const addResult = await addMissingAutofixKeysToTranslations(preScan.missingKeys.map((entry) => entry.key));
|
||||||
|
const scanResult = await runWorkspaceScan();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
mode,
|
||||||
|
fixedAt: new Date().toISOString(),
|
||||||
|
changedFileCount: 0,
|
||||||
|
changedFiles: [],
|
||||||
|
skippedFiles: [],
|
||||||
|
autoFixDebug: [],
|
||||||
|
createdKeyCount: addResult.createdKeys.length,
|
||||||
|
createdKeys: addResult.createdKeys,
|
||||||
|
...scanResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const fixResult = await runAutoFix({ targetFiles: targetFilesSet, forceConvertToClient });
|
const fixResult = await runAutoFix({ targetFiles: targetFilesSet, forceConvertToClient });
|
||||||
const scanResult = await runWorkspaceScan();
|
const scanResult = await runWorkspaceScan();
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,18 @@ import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
|||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useTranslation } from '../i18n/useTranslation'
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
import ConfirmActionModal from './modals/ConfirmActionModal'
|
import ConfirmActionModal from './modals/ConfirmActionModal'
|
||||||
|
import {
|
||||||
|
USER_STATUS_FILTER_OPTIONS,
|
||||||
|
getUserStatusBadgeClass,
|
||||||
|
getUserStatusLabel,
|
||||||
|
getUserTypeBadgeClass,
|
||||||
|
getUserTypeLabel,
|
||||||
|
getUserRoleBadgeClass,
|
||||||
|
getUserRoleLabel,
|
||||||
|
type ManagedUserStatus,
|
||||||
|
type ManagedUserType,
|
||||||
|
type ManagedUserRole,
|
||||||
|
} from '../admin/user-management/constants/userStatusPresentation'
|
||||||
|
|
||||||
interface UserDetailModalProps {
|
interface UserDetailModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@ -30,7 +42,7 @@ interface UserDetailModalProps {
|
|||||||
onUserUpdated?: () => void
|
onUserUpdated?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserStatus = 'inactive' | 'pending' | 'active' | 'suspended' | 'archived'
|
type UserStatus = ManagedUserStatus
|
||||||
|
|
||||||
type ContractFileItem = {
|
type ContractFileItem = {
|
||||||
key: string
|
key: string
|
||||||
@ -39,14 +51,6 @@ type ContractFileItem = {
|
|||||||
contract_type?: 'contract' | 'gdpr' | string | null
|
contract_type?: 'contract' | 'gdpr' | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
|
|
||||||
{ value: 'pending', label: 'Pending', color: 'amber' },
|
|
||||||
{ value: 'active', label: 'Active', color: 'green' },
|
|
||||||
{ value: 'suspended', label: 'Suspended', color: 'rose' },
|
|
||||||
{ value: 'archived', label: 'Archived', color: 'gray' },
|
|
||||||
{ value: 'inactive', label: 'Inactive', color: 'gray' }
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
||||||
@ -333,21 +337,6 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: UserStatus) => {
|
|
||||||
const option = STATUS_OPTIONS.find(opt => opt.value === status)
|
|
||||||
return option?.color || 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusBadgeClass = (color: string) => {
|
|
||||||
const colorMap: Record<string, string> = {
|
|
||||||
amber: 'bg-amber-100 text-amber-800 border-amber-200',
|
|
||||||
green: 'bg-green-100 text-green-800 border-green-200',
|
|
||||||
rose: 'bg-rose-100 text-rose-800 border-rose-200',
|
|
||||||
gray: 'bg-gray-100 text-gray-800 border-gray-200'
|
|
||||||
}
|
|
||||||
return colorMap[color] || colorMap.gray
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -362,11 +351,11 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity" />
|
<div className="fixed inset-0 bg-slate-900/35 backdrop-blur-sm transition-opacity" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-6">
|
<div className="flex min-h-full items-center justify-center p-2 text-center sm:p-4 lg:p-6">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="ease-out duration-300"
|
enter="ease-out duration-300"
|
||||||
@ -376,12 +365,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white shadow-xl transition-all w-full max-w-5xl max-h-[85vh] flex flex-col">
|
<Dialog.Panel className="relative transform overflow-hidden rounded-[28px] border border-white/80 bg-white/95 shadow-[0_24px_70px_-30px_rgba(15,23,42,0.35)] backdrop-blur transition-all w-full max-w-[1820px] max-h-[92vh] flex flex-col">
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
|
<div className="absolute right-0 top-0 z-10 pr-4 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
className="rounded-md bg-white text-slate-400 hover:text-slate-600 focus:outline-none focus:ring-2 focus:ring-slate-700 focus:ring-offset-2"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
@ -390,7 +379,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content Area */}
|
{/* Scrollable Content Area */}
|
||||||
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6">
|
<div className="overflow-y-auto px-4 pb-4 pt-5 sm:p-6 lg:p-8">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="flex justify-center items-center py-12">
|
||||||
@ -409,37 +398,33 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
) : userDetails ? (
|
) : userDetails ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header Section with User Info & Status */}
|
{/* Header Section with User Info & Status */}
|
||||||
<div className="bg-gradient-to-r from-indigo-500 to-purple-600 rounded-lg px-6 py-8 text-white">
|
<div className="rounded-2xl border border-white/80 bg-white/90 px-6 py-8 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.35)]">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="bg-white/20 backdrop-blur-sm p-4 rounded-full">
|
<div className="bg-slate-100 p-4 rounded-full">
|
||||||
{userDetails.user.user_type === 'company' ? (
|
{userDetails.user.user_type === 'company' ? (
|
||||||
<BuildingOfficeIcon className="h-10 w-10 text-white" />
|
<BuildingOfficeIcon className="h-10 w-10 text-slate-700" />
|
||||||
) : (
|
) : (
|
||||||
<UserIcon className="h-10 w-10 text-white" />
|
<UserIcon className="h-10 w-10 text-slate-700" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h2 className="text-2xl font-bold">
|
<h2 className="text-2xl font-bold text-slate-900">
|
||||||
{userDetails.user.user_type === 'personal'
|
{userDetails.user.user_type === 'personal'
|
||||||
? `${userDetails.personalProfile?.first_name || ''} ${userDetails.personalProfile?.last_name || ''}`.trim()
|
? `${userDetails.personalProfile?.first_name || ''} ${userDetails.personalProfile?.last_name || ''}`.trim()
|
||||||
: userDetails.companyProfile?.company_name || 'Unknown'}
|
: userDetails.companyProfile?.company_name || 'Unknown'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-indigo-100 mt-1">{userDetails.user.email}</p>
|
<p className="text-slate-600 mt-1">{userDetails.user.email}</p>
|
||||||
<div className="flex items-center gap-2 mt-3">
|
<div className="flex items-center gap-2 mt-3">
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
<span
|
||||||
userDetails.user.user_type === 'personal'
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${getUserTypeBadgeClass(userDetails.user.user_type as ManagedUserType)}`}
|
||||||
? 'bg-blue-100 text-blue-800'
|
>
|
||||||
: 'bg-purple-100 text-purple-800'
|
{getUserTypeLabel(t, userDetails.user.user_type as ManagedUserType)}
|
||||||
}`}>
|
|
||||||
{userDetails.user.user_type === 'personal' ? t('userDetailModal.personal') : t('userDetailModal.company')}
|
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
<span
|
||||||
userDetails.user.role === 'admin' || userDetails.user.role === 'super_admin'
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${getUserRoleBadgeClass(userDetails.user.role as ManagedUserRole)}`}
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
>
|
||||||
: 'bg-gray-100 text-gray-800'
|
{getUserRoleLabel(t, userDetails.user.role as ManagedUserRole)}
|
||||||
}`}>
|
|
||||||
{userDetails.user.role === 'super_admin' ? t('userDetailModal.superAdmin') : userDetails.user.role}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -447,12 +432,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
|
|
||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
{userDetails.userStatus && (
|
{userDetails.userStatus && (
|
||||||
<div className="bg-white rounded-lg px-4 py-3 text-gray-900">
|
<div className="bg-slate-50 rounded-lg px-4 py-3 text-slate-900 border border-slate-200">
|
||||||
<div className="text-xs text-gray-500 mb-1">{t('userDetailModal.currentStatus')}</div>
|
<div className="text-xs text-slate-500 mb-1">{t('userDetailModal.currentStatus')}</div>
|
||||||
<div className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${
|
<div className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${
|
||||||
getStatusBadgeClass(getStatusColor(userDetails.userStatus.status as UserStatus))
|
getUserStatusBadgeClass(userDetails.userStatus.status as UserStatus)
|
||||||
}`}>
|
}`}>
|
||||||
{userDetails.userStatus.status.charAt(0).toUpperCase() + userDetails.userStatus.status.slice(1)}
|
{getUserStatusLabel(t, userDetails.userStatus.status as UserStatus)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -460,9 +445,9 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Controls Section */}
|
{/* Admin Controls Section */}
|
||||||
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
|
<div className="rounded-2xl border border-white/80 bg-white/90 p-6 shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
|
<ShieldCheckIcon className="h-5 w-5 text-slate-700" />
|
||||||
{t('userDetailModal.adminControls')}
|
{t('userDetailModal.adminControls')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -481,17 +466,17 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Status Dropdown */}
|
{/* Status Dropdown */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
{t('userDetailModal.changeStatus')}
|
{t('userDetailModal.changeStatus')}
|
||||||
</label>
|
</label>
|
||||||
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
|
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white py-2.5 pl-3 pr-10 text-left border border-gray-300 hover:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-black">
|
<Listbox.Button className="relative w-full cursor-pointer rounded-lg bg-white py-2.5 pl-3 pr-10 text-left border border-slate-200 hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-700 disabled:opacity-50 disabled:cursor-not-allowed text-slate-900">
|
||||||
<span className="block truncate font-medium text-black">
|
<span className="block truncate font-medium text-black">
|
||||||
{STATUS_OPTIONS.find(opt => opt.value === selectedStatus)?.label || selectedStatus}
|
{getUserStatusLabel(t, selectedStatus)}
|
||||||
</span>
|
</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronUpDownIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
<ChevronUpDownIcon className="h-5 w-5 text-slate-400" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</Listbox.Button>
|
</Listbox.Button>
|
||||||
<Transition
|
<Transition
|
||||||
@ -500,24 +485,24 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-slate-200 focus:outline-none sm:text-sm">
|
||||||
{STATUS_OPTIONS.map((option) => (
|
{USER_STATUS_FILTER_OPTIONS.map((statusOption) => (
|
||||||
<Listbox.Option
|
<Listbox.Option
|
||||||
key={option.value}
|
key={statusOption}
|
||||||
className={({ active }) =>
|
className={({ active }) =>
|
||||||
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
|
||||||
active ? 'bg-indigo-100 text-indigo-900' : 'text-gray-900'
|
active ? 'bg-slate-100 text-slate-900' : 'text-slate-900'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
value={option.value}
|
value={statusOption}
|
||||||
>
|
>
|
||||||
{({ selected }) => (
|
{({ selected }) => (
|
||||||
<>
|
<>
|
||||||
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
|
<span className={`block truncate ${selected ? 'font-semibold' : 'font-normal'}`}>
|
||||||
{option.label}
|
{getUserStatusLabel(t, statusOption)}
|
||||||
</span>
|
</span>
|
||||||
{selected && (
|
{selected && (
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-indigo-600">
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-slate-700">
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -533,11 +518,11 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
|
|
||||||
{/* Admin Verification Toggle */}
|
{/* Admin Verification Toggle */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||||
{t('userDetailModal.adminVerification')}
|
{t('userDetailModal.adminVerification')}
|
||||||
</label>
|
</label>
|
||||||
{userDetails?.userStatus && (
|
{userDetails?.userStatus && (
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
<p className="text-xs text-slate-500 mb-2">
|
||||||
{canVerify
|
{canVerify
|
||||||
? t('userDetailModal.allStepsCompleted')
|
? t('userDetailModal.allStepsCompleted')
|
||||||
: t('userDetailModal.stepsNotCompleted')}
|
: t('userDetailModal.stepsNotCompleted')}
|
||||||
@ -571,19 +556,19 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contract Preview (admin verify flow) */}
|
{/* Contract Preview (admin verify flow) */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="rounded-2xl border border-white/80 bg-white/90 overflow-hidden shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
<DocumentTextIcon className="h-5 w-5 text-slate-700" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-lg font-semibold text-gray-900">{t('userDetailModal.contractPreview')}</span>
|
<span className="text-lg font-semibold text-slate-900">{t('userDetailModal.contractPreview')}</span>
|
||||||
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1">
|
<div className="flex items-center gap-1 rounded-full border border-slate-200 bg-white px-1">
|
||||||
{(['contract','gdpr'] as const).map((tab) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActivePreviewTab(tab)}
|
onClick={() => setActivePreviewTab(tab)}
|
||||||
className={`px-2.5 py-1 text-xs rounded-full transition ${activePreviewTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
|
className={`px-2.5 py-1 text-xs rounded-full transition ${activePreviewTab === tab ? 'bg-slate-900 text-white shadow' : 'text-slate-700 hover:bg-slate-100'}`}
|
||||||
>
|
>
|
||||||
{tab === 'contract' ? t('userDetailModal.contractTab') : t('userDetailModal.gdprTab')}
|
{tab === 'contract' ? t('userDetailModal.contractTab') : t('userDetailModal.gdprTab')}
|
||||||
</button>
|
</button>
|
||||||
@ -601,7 +586,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
loadContractPreview(activePreviewTab, item?.documentId || undefined, item?.key)
|
loadContractPreview(activePreviewTab, item?.documentId || undefined, item?.key)
|
||||||
}}
|
}}
|
||||||
disabled={previewState[activePreviewTab].loading}
|
disabled={previewState[activePreviewTab].loading}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-slate-900 hover:bg-slate-800 text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{previewState[activePreviewTab].loading ? t('userDetailModal.loadingPreview') : t('userDetailModal.preview')}
|
{previewState[activePreviewTab].loading ? t('userDetailModal.loadingPreview') : t('userDetailModal.preview')}
|
||||||
</button>
|
</button>
|
||||||
@ -615,7 +600,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
}}
|
}}
|
||||||
disabled={!previewState[activePreviewTab]?.html}
|
disabled={!previewState[activePreviewTab]?.html}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-slate-100 hover:bg-slate-200 text-slate-900 px-3 py-2 text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{t('userDetailModal.openInNewTab')}
|
{t('userDetailModal.openInNewTab')}
|
||||||
</button>
|
</button>
|
||||||
@ -631,23 +616,23 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="text-sm font-semibold text-gray-900">{t('userDetailModal.filesIn')} {activePreviewTab.toUpperCase()}</div>
|
<div className="text-sm font-semibold text-slate-900">{t('userDetailModal.filesIn')} {activePreviewTab.toUpperCase()}</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => loadContractFiles()}
|
onClick={() => loadContractFiles()}
|
||||||
disabled={docsLoading}
|
disabled={docsLoading}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-slate-100 hover:bg-slate-200 text-slate-900 px-3 py-1.5 text-xs disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{docsLoading ? t('userDetailModal.refreshing') : t('userDetailModal.refresh')}
|
{docsLoading ? t('userDetailModal.refreshing') : t('userDetailModal.refresh')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{docsLoading && (
|
{docsLoading && (
|
||||||
<div className="mt-2 text-xs text-gray-500">{t('userDetailModal.loadingFiles')}</div>
|
<div className="mt-2 text-xs text-slate-500">{t('userDetailModal.loadingFiles')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!docsLoading && files.length === 0 && (
|
{!docsLoading && files.length === 0 && (
|
||||||
<div className="mt-2 text-xs text-gray-500">{t('userDetailModal.noFilesFound')}</div>
|
<div className="mt-2 text-xs text-slate-500">{t('userDetailModal.noFilesFound')}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!docsLoading && files.length > 0 && (
|
{!docsLoading && files.length > 0 && (
|
||||||
@ -662,7 +647,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
setSelectedFile((prev) => ({ ...prev, [activePreviewTab]: f.key }))
|
setSelectedFile((prev) => ({ ...prev, [activePreviewTab]: f.key }))
|
||||||
loadContractPreview(activePreviewTab, f.documentId || undefined, f.key)
|
loadContractPreview(activePreviewTab, f.documentId || undefined, f.key)
|
||||||
}}
|
}}
|
||||||
className={`px-2.5 py-1 text-xs rounded-md border transition ${selectedKey === f.key ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'}`}
|
className={`px-2.5 py-1 text-xs rounded-md border transition ${selectedKey === f.key ? 'bg-slate-900 text-white border-slate-900' : 'bg-white text-slate-700 border-slate-200 hover:bg-slate-50'}`}
|
||||||
>
|
>
|
||||||
{f.filename}
|
{f.filename}
|
||||||
</button>
|
</button>
|
||||||
@ -671,14 +656,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<div className="mt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-xs text-gray-600">
|
<div className="mt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-xs text-slate-600">
|
||||||
<div className="truncate">{t('userDetailModal.selected')} {selectedItem.filename}</div>
|
<div className="truncate">{t('userDetailModal.selected')} {selectedItem.filename}</div>
|
||||||
{files.length >= 1 && (
|
{files.length >= 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => moveContractDoc(selectedItem.documentId || undefined, moveTarget as 'contract' | 'gdpr', selectedItem.filename, selectedItem.key)}
|
onClick={() => moveContractDoc(selectedItem.documentId || undefined, moveTarget as 'contract' | 'gdpr', selectedItem.filename, selectedItem.key)}
|
||||||
disabled={isMoving}
|
disabled={isMoving}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-slate-100 hover:bg-slate-200 text-slate-900 px-3 py-1.5 text-xs disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isMoving ? t('userDetailModal.moving') : `${t('userDetailModal.moveTo')} ${moveTarget.toUpperCase()}`}
|
{isMoving ? t('userDetailModal.moving') : `${t('userDetailModal.moveTo')} ${moveTarget.toUpperCase()}`}
|
||||||
</button>
|
</button>
|
||||||
@ -701,12 +686,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewState[activePreviewTab].loading && (
|
{previewState[activePreviewTab].loading && (
|
||||||
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
<div className="flex items-center justify-center h-40 text-sm text-slate-500">
|
||||||
{t('userDetailModal.loadingPreviewText')}
|
{t('userDetailModal.loadingPreviewText')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
||||||
<div className="rounded-md border border-gray-200 overflow-hidden">
|
<div className="rounded-md border border-slate-200 overflow-hidden">
|
||||||
<iframe
|
<iframe
|
||||||
title={`Contract Preview ${activePreviewTab}`}
|
title={`Contract Preview ${activePreviewTab}`}
|
||||||
className="w-full h-[600px] bg-white"
|
className="w-full h-[600px] bg-white"
|
||||||
@ -715,50 +700,50 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
|
{!previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
|
||||||
<p className="text-sm text-gray-500">{t('userDetailModal.clickPreviewHint')}</p>
|
<p className="text-sm text-slate-500">{t('userDetailModal.clickPreviewHint')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Information */}
|
{/* Profile Information */}
|
||||||
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
|
{userDetails.user.user_type === 'personal' && userDetails.personalProfile && (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="rounded-2xl border border-white/80 bg-white/90 overflow-hidden shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
<UserIcon className="h-5 w-5 text-slate-700" />
|
||||||
{t('userDetailModal.personalInformation')}
|
{t('userDetailModal.personalInformation')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.firstName')}</dt>
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">{t('userDetailModal.firstName')}</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.lastName')}</dt>
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">{t('userDetailModal.lastName')}</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
{t('userDetailModal.phone')}
|
{t('userDetailModal.phone')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">
|
||||||
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
|
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
{t('userDetailModal.dateOfBirth')}
|
{t('userDetailModal.dateOfBirth')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
{t('userDetailModal.address')}
|
{t('userDetailModal.address')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
<dd className="text-sm text-slate-900 font-medium">
|
||||||
{userDetails.personalProfile.address || 'N/A'}
|
{userDetails.personalProfile.address || 'N/A'}
|
||||||
{userDetails.personalProfile.city && <>, {userDetails.personalProfile.city}</>}
|
{userDetails.personalProfile.city && <>, {userDetails.personalProfile.city}</>}
|
||||||
{userDetails.personalProfile.zip_code && <>, {userDetails.personalProfile.zip_code}</>}
|
{userDetails.personalProfile.zip_code && <>, {userDetails.personalProfile.zip_code}</>}
|
||||||
@ -772,40 +757,40 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
|
|
||||||
{/* Company Profile Information */}
|
{/* Company Profile Information */}
|
||||||
{userDetails.user.user_type === 'company' && userDetails.companyProfile && (
|
{userDetails.user.user_type === 'company' && userDetails.companyProfile && (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="rounded-2xl border border-white/80 bg-white/90 overflow-hidden shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
|
<BuildingOfficeIcon className="h-5 w-5 text-slate-700" />
|
||||||
{t('userDetailModal.companyInformation')}
|
{t('userDetailModal.companyInformation')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.companyName')}</dt>
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">{t('userDetailModal.companyName')}</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.registrationNumber')}</dt>
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">{t('userDetailModal.registrationNumber')}</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.taxId')}</dt>
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">{t('userDetailModal.taxId')}</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
{t('userDetailModal.phone')}
|
{t('userDetailModal.phone')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
|
<dd className="text-sm text-slate-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-slate-500 mb-1.5">
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
{t('userDetailModal.address')}
|
{t('userDetailModal.address')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
<dd className="text-sm text-slate-900 font-medium">
|
||||||
{userDetails.companyProfile.address || 'N/A'}
|
{userDetails.companyProfile.address || 'N/A'}
|
||||||
{userDetails.companyProfile.city && <>, {userDetails.companyProfile.city}</>}
|
{userDetails.companyProfile.city && <>, {userDetails.companyProfile.city}</>}
|
||||||
{userDetails.companyProfile.zip_code && <>, {userDetails.companyProfile.zip_code}</>}
|
{userDetails.companyProfile.zip_code && <>, {userDetails.companyProfile.zip_code}</>}
|
||||||
@ -819,10 +804,10 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
|
|
||||||
{/* Account Status */}
|
{/* Account Status */}
|
||||||
{userDetails.userStatus && (
|
{userDetails.userStatus && (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="rounded-2xl border border-white/80 bg-white/90 overflow-hidden shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
|
<CheckCircleIcon className="h-5 w-5 text-slate-700" />
|
||||||
{t('userDetailModal.registrationProgress')}
|
{t('userDetailModal.registrationProgress')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@ -832,33 +817,33 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{userDetails.userStatus.email_verified === 1 ? (
|
{userDetails.userStatus.email_verified === 1 ? (
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
<XCircleIcon className="h-6 w-6 text-slate-300" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.emailVerified')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('userDetailModal.emailVerified')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.profile_completed === 1 ? (
|
{userDetails.userStatus.profile_completed === 1 ? (
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
<XCircleIcon className="h-6 w-6 text-slate-300" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.profileCompleted')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('userDetailModal.profileCompleted')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.documents_uploaded === 1 ? (
|
{userDetails.userStatus.documents_uploaded === 1 ? (
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
<XCircleIcon className="h-6 w-6 text-slate-300" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.documentsUploaded')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('userDetailModal.documentsUploaded')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.contract_signed === 1 ? (
|
{userDetails.userStatus.contract_signed === 1 ? (
|
||||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<XCircleIcon className="h-6 w-6 text-gray-300" />
|
<XCircleIcon className="h-6 w-6 text-slate-300" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.contractSigned')}</span>
|
<span className="text-sm font-medium text-slate-700">{t('userDetailModal.contractSigned')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -866,17 +851,17 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permissions */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="rounded-2xl border border-white/80 bg-white/90 overflow-hidden shadow-[0_20px_55px_-38px_rgba(15,23,42,0.3)]">
|
||||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div className="bg-slate-50 px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
|
<ShieldCheckIcon className="h-5 w-5 text-slate-700" />
|
||||||
{t('userDetailModal.permissions')} ({selectedPermissions.length})
|
{t('userDetailModal.permissions')} ({selectedPermissions.length})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSavePermissions}
|
onClick={handleSavePermissions}
|
||||||
disabled={permissionsSaving || permissionsLoading}
|
disabled={permissionsSaving || permissionsLoading}
|
||||||
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
|
className="inline-flex items-center justify-center rounded-md bg-slate-900 hover:bg-slate-800 text-white px-3 py-2 text-sm disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{permissionsSaving ? t('userDetailModal.savingPermissions') : t('userDetailModal.savePermissions')}
|
{permissionsSaving ? t('userDetailModal.savingPermissions') : t('userDetailModal.savePermissions')}
|
||||||
</button>
|
</button>
|
||||||
@ -888,12 +873,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{permissionsLoading ? (
|
{permissionsLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
<div className="h-4 w-4 border-2 border-gray-400 border-b-transparent rounded-full animate-spin" />
|
<div className="h-4 w-4 border-2 border-slate-400 border-b-transparent rounded-full animate-spin" />
|
||||||
{t('userDetailModal.loadingPermissions')}
|
{t('userDetailModal.loadingPermissions')}
|
||||||
</div>
|
</div>
|
||||||
) : allPermissions.length === 0 ? (
|
) : allPermissions.length === 0 ? (
|
||||||
<div className="text-sm text-gray-500">{t('userDetailModal.noPermissionsAvailable')}</div>
|
<div className="text-sm text-slate-500">{t('userDetailModal.noPermissionsAvailable')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{allPermissions.map((perm) => {
|
{allPermissions.map((perm) => {
|
||||||
@ -903,23 +888,23 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<label
|
<label
|
||||||
key={perm.id}
|
key={perm.id}
|
||||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer ${
|
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer ${
|
||||||
disabled ? 'bg-gray-50 border-gray-200 opacity-70 cursor-not-allowed' : checked ? 'bg-green-50 border-green-200' : 'bg-white border-gray-200'
|
disabled ? 'bg-slate-50 border-slate-200 opacity-70 cursor-not-allowed' : checked ? 'bg-green-50 border-green-200' : 'bg-white border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-slate-700 focus:ring-slate-500"
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled || permissionsSaving}
|
disabled={disabled || permissionsSaving}
|
||||||
onChange={() => togglePermission(perm.name)}
|
onChange={() => togglePermission(perm.name)}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">{perm.name}</div>
|
<div className="text-sm font-medium text-slate-900">{perm.name}</div>
|
||||||
{perm.description && (
|
{perm.description && (
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{perm.description}</div>
|
<div className="text-xs text-slate-500 mt-0.5">{perm.description}</div>
|
||||||
)}
|
)}
|
||||||
{!perm.is_active && (
|
{!perm.is_active && (
|
||||||
<div className="text-xs text-gray-400 mt-0.5">{t('userDetailModal.inactive')}</div>
|
<div className="text-xs text-slate-400 mt-0.5">{t('userDetailModal.inactive')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -935,7 +920,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
|
className="inline-flex items-center justify-center gap-2 rounded-lg bg-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-900 shadow-sm hover:bg-slate-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-slate-500"
|
||||||
>
|
>
|
||||||
{t('userDetailModal.close')}
|
{t('userDetailModal.close')}
|
||||||
</button>
|
</button>
|
||||||
@ -960,7 +945,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
onConfirm={confirmMoveContractDoc}
|
onConfirm={confirmMoveContractDoc}
|
||||||
extraContent={
|
extraContent={
|
||||||
moveConfirm?.filename ? (
|
moveConfirm?.filename ? (
|
||||||
<div className="text-xs text-gray-600">{t('userDetailModal.moveDocumentFile')} {moveConfirm.filename}</div>
|
<div className="text-xs text-slate-600">{t('userDetailModal.moveDocumentFile')} {moveConfirm.filename}</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
30
src/app/dashboard/components/DashboardGoldMemberCard.tsx
Normal file
30
src/app/dashboard/components/DashboardGoldMemberCard.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { StarIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export default function DashboardGoldMemberCard() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] p-5 text-white shadow-[0_24px_60px_-36px_rgba(141,107,29,0.9)] sm:p-7">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
|
<StarIcon className="h-11 w-11 text-yellow-300 shrink-0" />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-2xl font-bold leading-tight break-words">{t('dashboard.goldMemberTitle')}</h2>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-yellow-100 break-words">{t('dashboard.goldMemberDescription')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:ml-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl bg-white/20 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-white/30"
|
||||||
|
>
|
||||||
|
{t('dashboard.viewBenefits')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
src/app/dashboard/components/DashboardLatestNewsCard.tsx
Normal file
75
src/app/dashboard/components/DashboardLatestNewsCard.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
import type { DashboardNewsItem } from '../hooks/useDashboardData'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
latestNews: DashboardNewsItem[]
|
||||||
|
newsLoading: boolean
|
||||||
|
newsError: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocale(language: string): string {
|
||||||
|
if (language === 'de') return 'de-DE'
|
||||||
|
if (language === 'en') return 'en-US'
|
||||||
|
return language
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLatestNewsCard({ latestNews, newsLoading, newsError }: Props) {
|
||||||
|
const { t, language } = useTranslation()
|
||||||
|
const locale = toLocale(language)
|
||||||
|
|
||||||
|
const formatDate = (value: string | null | undefined) => {
|
||||||
|
if (!value) return t('dashboard.recent')
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return t('dashboard.recent')
|
||||||
|
return date.toLocaleDateString(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur sm:p-7">
|
||||||
|
<div className="mb-5 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<h2 className="text-lg font-bold text-slate-950 break-words">{t('dashboard.latestNews')}</h2>
|
||||||
|
<Link href="/news" className="text-sm font-semibold text-blue-900 hover:text-blue-700 break-words">
|
||||||
|
{t('dashboard.viewAllNews')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newsLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="animate-pulse space-y-2">
|
||||||
|
<div className="h-4 w-2/3 rounded bg-slate-200" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newsError && !newsLoading && <div className="text-sm text-red-600 break-words">{newsError}</div>}
|
||||||
|
|
||||||
|
{!newsLoading && !newsError && latestNews.length === 0 && (
|
||||||
|
<div className="text-sm text-slate-600 break-words">{t('dashboard.noNewsYet')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!newsLoading && !newsError && latestNews.length > 0 && (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{latestNews.map((item) => (
|
||||||
|
<li key={item.id} className="group">
|
||||||
|
<Link href={`/news/${item.slug}`} className="block min-w-0">
|
||||||
|
<div className="text-xs text-slate-500 break-words">{formatDate(item.published_at)}</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-900 group-hover:text-blue-700 break-words">
|
||||||
|
{item.title}
|
||||||
|
</div>
|
||||||
|
{item.summary && (
|
||||||
|
<div className="mt-1 text-xs text-slate-600 break-words leading-5">{item.summary}</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/app/dashboard/components/DashboardLoadingState.tsx
Normal file
16
src/app/dashboard/components/DashboardLoadingState.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export default function DashboardLoadingState() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4" />
|
||||||
|
<p className="text-[#4A4A4A] break-words">{t('dashboard.loading')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
117
src/app/dashboard/components/DashboardPlatformsCard.tsx
Normal file
117
src/app/dashboard/components/DashboardPlatformsCard.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { ComponentType, SVGProps } from 'react'
|
||||||
|
import {
|
||||||
|
ShoppingBagIcon,
|
||||||
|
UsersIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
LinkIcon,
|
||||||
|
} from '@heroicons/react/24/outline'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
import type { DashboardPlatform, DashboardPlatformIconName } from '../../utils/dashboardPlatforms'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
platforms: DashboardPlatform[]
|
||||||
|
isShopEnabled: boolean
|
||||||
|
onNavigate: (href: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformTitleKeyById: Record<string, string> = {
|
||||||
|
shop: 'dashboard.platformCards.shop.title',
|
||||||
|
'affiliate-links': 'dashboard.platformCards.affiliateLinks.title',
|
||||||
|
'referral-management': 'dashboard.platformCards.referralManagement.title',
|
||||||
|
profile: 'dashboard.platformCards.profile.title',
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformDescriptionKeyById: Record<string, string> = {
|
||||||
|
shop: 'dashboard.platformCards.shop.description',
|
||||||
|
'affiliate-links': 'dashboard.platformCards.affiliateLinks.description',
|
||||||
|
'referral-management': 'dashboard.platformCards.referralManagement.description',
|
||||||
|
profile: 'dashboard.platformCards.profile.description',
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons: Record<DashboardPlatformIconName, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||||
|
ShoppingBagIcon,
|
||||||
|
LinkIcon,
|
||||||
|
UsersIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPlatformsCard({ platforms, isShopEnabled, onNavigate }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const getTranslatedOrFallback = (key: string, fallback: string) => {
|
||||||
|
const translated = t(key)
|
||||||
|
return translated === key ? fallback : translated
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur sm:p-7">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap mb-5">
|
||||||
|
<h2 className="text-xl font-bold text-slate-950 break-words">{t('dashboard.platforms')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{platforms.map((platform) => {
|
||||||
|
const Icon = icons[platform.icon] || LinkIcon
|
||||||
|
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
|
||||||
|
const isDisabled = Boolean(platform.disabled) || disabledByEnv
|
||||||
|
const titleKey = platform.titleKey || platformTitleKeyById[platform.id]
|
||||||
|
const descriptionKey = platform.descriptionKey || platformDescriptionKeyById[platform.id]
|
||||||
|
const translatedTitle = titleKey
|
||||||
|
? getTranslatedOrFallback(titleKey, platform.title)
|
||||||
|
: platform.title
|
||||||
|
const translatedDescription = descriptionKey
|
||||||
|
? getTranslatedOrFallback(descriptionKey, platform.description)
|
||||||
|
: platform.description
|
||||||
|
const disabledText = disabledByEnv ? t('dashboard.platformDisabled') : platform.disabledText
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={platform.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isDisabled) {
|
||||||
|
onNavigate(platform.href)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={`group h-full rounded-2xl border p-5 text-left transition-all duration-200 ${
|
||||||
|
isDisabled
|
||||||
|
? 'border-slate-200 bg-white/70 opacity-60 cursor-not-allowed'
|
||||||
|
: 'border-slate-200 bg-white shadow-sm hover:shadow-md hover:-translate-y-0.5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`${platform.color} rounded-xl p-3 shrink-0 ${
|
||||||
|
isDisabled ? 'grayscale' : 'transition-transform group-hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3
|
||||||
|
className={`text-base font-semibold leading-snug break-words ${
|
||||||
|
isDisabled ? 'text-slate-500' : 'text-slate-900 group-hover:text-[#8D6B1D]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{translatedTitle}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm leading-6 text-slate-600 break-words">
|
||||||
|
{translatedDescription}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isDisabled && disabledText && (
|
||||||
|
<p className="mt-3 text-xs font-medium text-amber-700 break-words">{disabledText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/app/dashboard/components/DashboardRedirectOverlay.tsx
Normal file
16
src/app/dashboard/components/DashboardRedirectOverlay.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
|
export default function DashboardRedirectOverlay() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white/90 px-5 py-4 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.45)]">
|
||||||
|
<div className="text-sm font-semibold text-slate-900 break-words">{t('dashboard.redirecting')}</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-600 break-words">{t('dashboard.pleaseWait')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/app/dashboard/components/DashboardWelcomeCard.tsx
Normal file
25
src/app/dashboard/components/DashboardWelcomeCard.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
userName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardWelcomeCard({ userName }: Props) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-[30px] border border-white/80 bg-white/85 p-5 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur sm:p-8">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
|
Dashboard
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl font-black tracking-tight leading-tight text-slate-950 sm:text-4xl break-words">
|
||||||
|
{t('dashboard.welcomeBack')}, {userName}!
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-600 break-words">
|
||||||
|
{t('dashboard.welcomeSubtitle')}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
src/app/dashboard/hooks/useDashboardData.ts
Normal file
173
src/app/dashboard/hooks/useDashboardData.ts
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import useAuthStore from '../../store/authStore'
|
||||||
|
import { useUserStatus } from '../../hooks/useUserStatus'
|
||||||
|
import {
|
||||||
|
DEFAULT_DASHBOARD_PLATFORMS,
|
||||||
|
loadDashboardPlatforms,
|
||||||
|
subscribeDashboardPlatformsUpdated,
|
||||||
|
type DashboardPlatform,
|
||||||
|
} from '../../utils/dashboardPlatforms'
|
||||||
|
|
||||||
|
export type DashboardNewsItem = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
summary?: string
|
||||||
|
slug: string
|
||||||
|
published_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const NEWS_API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
|
|
||||||
|
export function useDashboardData() {
|
||||||
|
const router = useRouter()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const isAuthReady = useAuthStore((state) => state.isAuthReady)
|
||||||
|
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
||||||
|
const [platforms, setPlatforms] = useState<DashboardPlatform[]>(DEFAULT_DASHBOARD_PLATFORMS)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
const [latestNews, setLatestNews] = useState<DashboardNewsItem[]>([])
|
||||||
|
const [newsLoading, setNewsLoading] = useState(false)
|
||||||
|
const [newsError, setNewsError] = useState<string | null>(null)
|
||||||
|
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { userStatus, loading: statusLoading } = useUserStatus()
|
||||||
|
const redirectOnceRef = useRef(false)
|
||||||
|
|
||||||
|
const smoothReplace = useCallback(
|
||||||
|
(to: string) => {
|
||||||
|
if (redirectOnceRef.current) return
|
||||||
|
redirectOnceRef.current = true
|
||||||
|
setRedirectTo(to)
|
||||||
|
window.setTimeout(() => router.replace(to), 200)
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
const navigateTo = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
router.push(href)
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 768px)')
|
||||||
|
const apply = () => setIsMobile(mq.matches)
|
||||||
|
apply()
|
||||||
|
|
||||||
|
mq.addEventListener?.('change', apply)
|
||||||
|
window.addEventListener('resize', apply, { passive: true })
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mq.removeEventListener?.('change', apply)
|
||||||
|
window.removeEventListener('resize', apply)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlatforms(loadDashboardPlatforms())
|
||||||
|
return subscribeDashboardPlatformsUpdated(() => setPlatforms(loadDashboardPlatforms()))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
|
||||||
|
const fetchLatestNews = async () => {
|
||||||
|
setNewsLoading(true)
|
||||||
|
setNewsError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${NEWS_API_BASE_URL}/api/news/active`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch news')
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as { data?: unknown } | null
|
||||||
|
const newsItems = Array.isArray(payload?.data) ? (payload.data as DashboardNewsItem[]) : []
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
setLatestNews(newsItems.slice(0, 3))
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (!active) return
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
setNewsError(error.message)
|
||||||
|
} else {
|
||||||
|
setNewsError('Failed to load news')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (active) setNewsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchLatestNews()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthReady && !user) {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, [isAuthReady, user, router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthReady || !user) return
|
||||||
|
if (statusLoading || !userStatus) return
|
||||||
|
|
||||||
|
const isGuest = user.role === 'guest'
|
||||||
|
const isComplete = isGuest
|
||||||
|
? Boolean(userStatus.email_verified)
|
||||||
|
: Boolean(userStatus.email_verified) &&
|
||||||
|
Boolean(userStatus.documents_uploaded) &&
|
||||||
|
Boolean(userStatus.profile_completed) &&
|
||||||
|
Boolean(userStatus.contract_signed)
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
smoothReplace('/quickaction-dashboard')
|
||||||
|
}
|
||||||
|
}, [isAuthReady, user, statusLoading, userStatus, smoothReplace])
|
||||||
|
|
||||||
|
const allDone = useMemo(() => {
|
||||||
|
if (!userStatus) return false
|
||||||
|
|
||||||
|
const isGuest = user?.role === 'guest'
|
||||||
|
return isGuest
|
||||||
|
? Boolean(userStatus.email_verified)
|
||||||
|
: Boolean(userStatus.email_verified) &&
|
||||||
|
Boolean(userStatus.documents_uploaded) &&
|
||||||
|
Boolean(userStatus.profile_completed) &&
|
||||||
|
Boolean(userStatus.contract_signed)
|
||||||
|
}, [user, userStatus])
|
||||||
|
|
||||||
|
const userName = useMemo(() => {
|
||||||
|
if (!user) return 'User'
|
||||||
|
if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`
|
||||||
|
if (user.firstName) return user.firstName
|
||||||
|
if (user.email) return user.email.split('@')[0]
|
||||||
|
return 'User'
|
||||||
|
}, [user])
|
||||||
|
|
||||||
|
const activePlatforms = useMemo(() => platforms.filter((platform) => platform.isActive), [platforms])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthReady,
|
||||||
|
user,
|
||||||
|
userStatus,
|
||||||
|
statusLoading,
|
||||||
|
allDone,
|
||||||
|
redirectTo,
|
||||||
|
isShopEnabled,
|
||||||
|
isMobile,
|
||||||
|
userName,
|
||||||
|
activePlatforms,
|
||||||
|
latestNews,
|
||||||
|
newsLoading,
|
||||||
|
newsError,
|
||||||
|
navigateTo,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,342 +1,59 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
|
||||||
import type { ComponentType, SVGProps } from 'react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import useAuthStore from '../store/authStore'
|
|
||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import Waves from '../components/background/waves'
|
import Waves from '../components/background/waves'
|
||||||
import BlueBlurryBackground from '../components/background/blueblurry'
|
import BlueBlurryBackground from '../components/background/blueblurry'
|
||||||
import { useTranslation } from '../i18n/useTranslation'
|
import DashboardLoadingState from './components/DashboardLoadingState'
|
||||||
import { useUserStatus } from '../hooks/useUserStatus'
|
import DashboardRedirectOverlay from './components/DashboardRedirectOverlay'
|
||||||
import {
|
import DashboardWelcomeCard from './components/DashboardWelcomeCard'
|
||||||
ShoppingBagIcon,
|
import DashboardPlatformsCard from './components/DashboardPlatformsCard'
|
||||||
UsersIcon,
|
import DashboardGoldMemberCard from './components/DashboardGoldMemberCard'
|
||||||
UserCircleIcon,
|
import DashboardLatestNewsCard from './components/DashboardLatestNewsCard'
|
||||||
StarIcon,
|
import { useDashboardData } from './hooks/useDashboardData'
|
||||||
LinkIcon
|
|
||||||
} from '@heroicons/react/24/outline'
|
|
||||||
import {
|
|
||||||
DEFAULT_DASHBOARD_PLATFORMS,
|
|
||||||
loadDashboardPlatforms,
|
|
||||||
subscribeDashboardPlatformsUpdated,
|
|
||||||
type DashboardPlatform,
|
|
||||||
type DashboardPlatformIconName
|
|
||||||
} from '../utils/dashboardPlatforms'
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter()
|
const {
|
||||||
const { t } = useTranslation()
|
isAuthReady,
|
||||||
const user = useAuthStore(state => state.user)
|
user,
|
||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
userStatus,
|
||||||
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
statusLoading,
|
||||||
const [platforms, setPlatforms] = useState<DashboardPlatform[]>(DEFAULT_DASHBOARD_PLATFORMS)
|
allDone,
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
redirectTo,
|
||||||
const [latestNews, setLatestNews] = useState<Array<{ id: number; title: string; summary?: string; slug: string; published_at?: string | null }>>([])
|
isShopEnabled,
|
||||||
const [newsLoading, setNewsLoading] = useState(false)
|
isMobile,
|
||||||
const [newsError, setNewsError] = useState<string | null>(null)
|
userName,
|
||||||
|
activePlatforms,
|
||||||
|
latestNews,
|
||||||
|
newsLoading,
|
||||||
|
newsError,
|
||||||
|
navigateTo,
|
||||||
|
} = useDashboardData()
|
||||||
|
|
||||||
const { userStatus, loading: statusLoading } = useUserStatus()
|
|
||||||
|
|
||||||
// NEW: smooth redirect helper
|
|
||||||
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
|
||||||
const redirectOnceRef = useRef(false)
|
|
||||||
const smoothReplace = useCallback((to: string) => {
|
|
||||||
if (redirectOnceRef.current) return
|
|
||||||
redirectOnceRef.current = true
|
|
||||||
setRedirectTo(to)
|
|
||||||
window.setTimeout(() => router.replace(to), 200)
|
|
||||||
}, [router])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia('(max-width: 768px)')
|
|
||||||
const apply = () => setIsMobile(mq.matches)
|
|
||||||
apply()
|
|
||||||
mq.addEventListener?.('change', apply)
|
|
||||||
window.addEventListener('resize', apply, { passive: true })
|
|
||||||
return () => {
|
|
||||||
mq.removeEventListener?.('change', apply)
|
|
||||||
window.removeEventListener('resize', apply)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPlatforms(loadDashboardPlatforms())
|
|
||||||
return subscribeDashboardPlatformsUpdated(() => setPlatforms(loadDashboardPlatforms()))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true
|
|
||||||
;(async () => {
|
|
||||||
setNewsLoading(true)
|
|
||||||
setNewsError(null)
|
|
||||||
try {
|
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
|
||||||
const res = await fetch(`${BASE_URL}/api/news/active`)
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch news')
|
|
||||||
const json = await res.json()
|
|
||||||
const data = Array.isArray(json.data) ? json.data : []
|
|
||||||
if (active) setLatestNews(data.slice(0, 3))
|
|
||||||
} catch (e: any) {
|
|
||||||
if (active) setNewsError(e?.message || 'Failed to load news')
|
|
||||||
} finally {
|
|
||||||
if (active) setNewsLoading(false)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
return () => { active = false }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Redirect if not logged in (only after auth is ready)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthReady && !user) {
|
|
||||||
router.push('/login')
|
|
||||||
}
|
|
||||||
}, [isAuthReady, user, router])
|
|
||||||
|
|
||||||
// NEW: block dashboard unless all quickaction steps are completed
|
|
||||||
// For guest users: only email verification is required
|
|
||||||
// For regular users: all 4 steps must be completed
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthReady || !user) return
|
|
||||||
if (statusLoading || !userStatus) return
|
|
||||||
|
|
||||||
const isGuest = user?.role === 'guest'
|
|
||||||
const allDone = isGuest
|
|
||||||
? !!userStatus.email_verified
|
|
||||||
: !!userStatus.email_verified &&
|
|
||||||
!!userStatus.documents_uploaded &&
|
|
||||||
!!userStatus.profile_completed &&
|
|
||||||
!!userStatus.contract_signed
|
|
||||||
|
|
||||||
if (!allDone) smoothReplace('/quickaction-dashboard')
|
|
||||||
}, [isAuthReady, user, statusLoading, userStatus, smoothReplace])
|
|
||||||
|
|
||||||
// Show loading until auth is ready, user is confirmed, and (if logged in) status is loaded
|
|
||||||
if (!isAuthReady || !user || (statusLoading && user)) {
|
if (!isAuthReady || !user || (statusLoading && user)) {
|
||||||
return (
|
return <DashboardLoadingState />
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
|
||||||
<p className="text-[#4A4A4A]">{t('dashboard.loading')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If redirecting away, avoid rendering dashboard content
|
|
||||||
if (redirectTo) {
|
if (redirectTo) {
|
||||||
return (
|
return <DashboardRedirectOverlay />
|
||||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
|
|
||||||
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{t('dashboard.redirecting')}</div>
|
|
||||||
<div className="mt-1 text-xs text-gray-600">{t('dashboard.pleaseWait')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final guard (don't render dashboard if not all done)
|
if (!userStatus || !allDone) {
|
||||||
if (!userStatus) return null
|
return null
|
||||||
const isGuestUser = user?.role === 'guest'
|
|
||||||
const allDone = isGuestUser
|
|
||||||
? !!userStatus.email_verified
|
|
||||||
: !!userStatus.email_verified &&
|
|
||||||
!!userStatus.documents_uploaded &&
|
|
||||||
!!userStatus.profile_completed &&
|
|
||||||
!!userStatus.contract_signed
|
|
||||||
if (!allDone) return null
|
|
||||||
|
|
||||||
// Get user name
|
|
||||||
const getUserName = () => {
|
|
||||||
if (user.firstName && user.lastName) {
|
|
||||||
return `${user.firstName} ${user.lastName}`
|
|
||||||
}
|
|
||||||
if (user.firstName) return user.firstName
|
|
||||||
if (user.email) return user.email.split('@')[0]
|
|
||||||
return 'User'
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons: Record<DashboardPlatformIconName, ComponentType<SVGProps<SVGSVGElement>>> = {
|
|
||||||
ShoppingBagIcon,
|
|
||||||
LinkIcon,
|
|
||||||
UsersIcon,
|
|
||||||
UserCircleIcon
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTranslatedOrFallback = (key: string, fallback: string) => {
|
|
||||||
const translated = t(key)
|
|
||||||
return translated === key ? fallback : translated
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformTitleKeyById: Record<string, string> = {
|
|
||||||
'shop': 'dashboard.platformCards.shop.title',
|
|
||||||
'affiliate-links': 'dashboard.platformCards.affiliateLinks.title',
|
|
||||||
'referral-management': 'dashboard.platformCards.referralManagement.title',
|
|
||||||
'profile': 'dashboard.platformCards.profile.title',
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformDescriptionKeyById: Record<string, string> = {
|
|
||||||
'shop': 'dashboard.platformCards.shop.description',
|
|
||||||
'affiliate-links': 'dashboard.platformCards.affiliateLinks.description',
|
|
||||||
'referral-management': 'dashboard.platformCards.referralManagement.description',
|
|
||||||
'profile': 'dashboard.platformCards.profile.description',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<div className="relative z-10 flex-1 min-h-0">
|
<div className="relative z-10 flex-1 min-h-0">
|
||||||
<PageLayout className="bg-transparent text-gray-900">
|
<PageLayout className="bg-transparent text-slate-900">
|
||||||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto space-y-5">
|
||||||
<div className="rounded-3xl bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-8">
|
<DashboardWelcomeCard userName={userName} />
|
||||||
{/* Welcome Section */}
|
<DashboardPlatformsCard
|
||||||
<div className="mb-8">
|
platforms={activePlatforms}
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
isShopEnabled={isShopEnabled}
|
||||||
{t('dashboard.welcomeBack')}, {getUserName()}! 👋
|
onNavigate={navigateTo}
|
||||||
</h1>
|
/>
|
||||||
<p className="text-gray-600 mt-2">
|
<DashboardGoldMemberCard />
|
||||||
{t('dashboard.welcomeSubtitle')}
|
<DashboardLatestNewsCard latestNews={latestNews} newsLoading={newsLoading} newsError={newsError} />
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('dashboard.platforms')}</h2>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{platforms.filter(p => p.isActive).map((platform) => {
|
|
||||||
const Icon = icons[platform.icon]
|
|
||||||
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
|
|
||||||
const isDisabled = Boolean(platform.disabled) || disabledByEnv
|
|
||||||
const translatedTitle = platformTitleKeyById[platform.id]
|
|
||||||
? getTranslatedOrFallback(platformTitleKeyById[platform.id], platform.title)
|
|
||||||
: platform.title
|
|
||||||
const translatedDescription = platformDescriptionKeyById[platform.id]
|
|
||||||
? getTranslatedOrFallback(platformDescriptionKeyById[platform.id], platform.description)
|
|
||||||
: platform.description
|
|
||||||
const disabledText = disabledByEnv
|
|
||||||
? t('dashboard.platformDisabled')
|
|
||||||
: platform.disabledText
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={platform.id}
|
|
||||||
onClick={() => {
|
|
||||||
if (!isDisabled) {
|
|
||||||
router.push(platform.href)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDisabled}
|
|
||||||
className={`bg-white rounded-lg p-6 border border-gray-200 text-left group transition-all duration-200 ${
|
|
||||||
isDisabled
|
|
||||||
? 'opacity-60 cursor-not-allowed'
|
|
||||||
: 'shadow-sm hover:shadow-lg hover:-translate-y-1 hover:-translate-y-1 hover:-translate-y-1 transform hover:-translate-y-1'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div
|
|
||||||
className={`${platform.color} rounded-lg p-3 ${
|
|
||||||
isDisabled
|
|
||||||
? 'grayscale'
|
|
||||||
: 'group-hover:scale-105 transition-transform'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-6 w-6 text-white" />
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-1">
|
|
||||||
<h3
|
|
||||||
className={`text-lg font-medium transition-colors ${
|
|
||||||
isDisabled
|
|
||||||
? 'text-gray-500'
|
|
||||||
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{translatedTitle}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
{translatedDescription}
|
|
||||||
</p>
|
|
||||||
{isDisabled && disabledText && (
|
|
||||||
<p className="mt-3 text-xs font-medium text-amber-700">
|
|
||||||
{disabledText}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Gold Member Status */}
|
|
||||||
<div className="bg-gradient-to-r from-[#8D6B1D] to-[#B8860B] rounded-lg p-6 text-white mb-8">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<StarIcon className="h-12 w-12 text-yellow-300" />
|
|
||||||
<div className="ml-4">
|
|
||||||
<h2 className="text-2xl font-bold">{t('dashboard.goldMemberTitle')}</h2>
|
|
||||||
<p className="text-yellow-100 mt-1">
|
|
||||||
{t('dashboard.goldMemberDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto">
|
|
||||||
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
|
|
||||||
{t('dashboard.viewBenefits')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Latest News */}
|
|
||||||
<div className="rounded-2xl bg-white border border-gray-200 shadow-sm p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('dashboard.latestNews')}</h2>
|
|
||||||
<Link href="/news" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
|
||||||
{t('dashboard.viewAllNews')}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{newsLoading && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="animate-pulse space-y-2">
|
|
||||||
<div className="h-4 w-2/3 bg-gray-200 rounded" />
|
|
||||||
<div className="h-3 w-1/2 bg-gray-100 rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newsError && !newsLoading && (
|
|
||||||
<div className="text-sm text-red-600">{newsError}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!newsLoading && !newsError && latestNews.length === 0 && (
|
|
||||||
<div className="text-sm text-gray-600">{t('dashboard.noNewsYet')}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!newsLoading && !newsError && latestNews.length > 0 && (
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{latestNews.map(item => (
|
|
||||||
<li key={item.id} className="group">
|
|
||||||
<Link href={`/news/${item.slug}`} className="block">
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : t('dashboard.recent')}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-gray-900 group-hover:text-blue-700 line-clamp-2">
|
|
||||||
{item.title}
|
|
||||||
</div>
|
|
||||||
{item.summary && (
|
|
||||||
<div className="text-xs text-gray-600 line-clamp-2 mt-1">
|
|
||||||
{item.summary}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -207,7 +207,7 @@ export const de: Translations = {
|
|||||||
"accessDenied": "Zugriff verweigert",
|
"accessDenied": "Zugriff verweigert",
|
||||||
"accessDeniedMessage": "Sie müssen das Onboarding abschließen, um auf das Dashboard zuzugreifen.",
|
"accessDeniedMessage": "Sie müssen das Onboarding abschließen, um auf das Dashboard zuzugreifen.",
|
||||||
"welcomeBack": "Willkommen zurück",
|
"welcomeBack": "Willkommen zurück",
|
||||||
"welcomeSubtitle": "Das passiert gerade mit Ihrem Profit Planet Konto",
|
"welcomeSubtitle": "Hier gehts weiter!",
|
||||||
"platforms": "Plattformen",
|
"platforms": "Plattformen",
|
||||||
"platformDisabled": "Diese Funktion ist derzeit deaktiviert.",
|
"platformDisabled": "Diese Funktion ist derzeit deaktiviert.",
|
||||||
"redirecting": "Weiterleitung…",
|
"redirecting": "Weiterleitung…",
|
||||||
@ -1298,6 +1298,63 @@ export const de: Translations = {
|
|||||||
"k209ba561": "Neuen Pool erstellen",
|
"k209ba561": "Neuen Pool erstellen",
|
||||||
"k20ab2fc7": "Wir senden einen Verifizierungscode an Ihre E-Mail-Adresse.",
|
"k20ab2fc7": "Wir senden einen Verifizierungscode an Ihre E-Mail-Adresse.",
|
||||||
"k21440f8a": "Pool-Verwaltung",
|
"k21440f8a": "Pool-Verwaltung",
|
||||||
|
"k6f7f26a1": "Pool-Admin",
|
||||||
|
"k5f4d2c11": "{count} gesamt",
|
||||||
|
"ke2a1b003": "Inaktiv",
|
||||||
|
"k3bc84f12": "Aktiv",
|
||||||
|
"kfd227aa9": "Mitglieder",
|
||||||
|
"k91c8d444": "Erstellt",
|
||||||
|
"k7d2a1190": "Verwalten",
|
||||||
|
"kf3b0c221": "Archivieren",
|
||||||
|
"k1e2f3a44": "Keine inaktiven Pools gefunden.",
|
||||||
|
"ka8b3c104": "Keine aktiven Pools gefunden.",
|
||||||
|
"k0b84e6aa": "Pool archivieren?",
|
||||||
|
"k9ad214be": "Pool aktivieren?",
|
||||||
|
"k3fe81c2a": "Nutzer können diesem Pool während der Archivierung nicht mehr beitreten oder ihn nutzen.",
|
||||||
|
"k1d6c33f1": "Dieser Pool ist wieder aktiv und verfügbar.",
|
||||||
|
"k8fa13d9b": "Aktiv setzen",
|
||||||
|
"k1a0d4f73": "Pool konnte nicht deaktiviert werden.",
|
||||||
|
"k54a977c3": "Pool konnte nicht aktiviert werden.",
|
||||||
|
"k78dc5a11": "Unbenannter Pool",
|
||||||
|
"k8dca3321": "Unbenannter Nutzer",
|
||||||
|
"k7021ad54": "Pool-Mitglieder konnten nicht geladen werden.",
|
||||||
|
"k53f7e9a1": "Authentifizierung erforderlich.",
|
||||||
|
"k9c4d2ab3": "Nutzer konnten nicht gesucht werden.",
|
||||||
|
"k3f7ca220": "Nutzer konnte nicht hinzugefügt werden.",
|
||||||
|
"k90b5f8d1": "Nutzer konnten nicht hinzugefügt werden.",
|
||||||
|
"k296db6a0": "Nutzer konnte nicht aus dem Pool entfernt werden.",
|
||||||
|
"kf0c9a38d": "Nutzer verwalten und Pool-Einzahlungen verfolgen",
|
||||||
|
"kcf73e90d": "Core-Pool für gemeinsame Kapselkosten über alle Mitglieder hinweg.",
|
||||||
|
"k20f6ac90": "Pool für Zuflüsse aus Kaffee-Abonnements und Kapselnutzung der Mitglieder.",
|
||||||
|
"k4bc91d55": "Allgemeiner Pool für zusätzliche Abonnements und manuelle Zuweisungen.",
|
||||||
|
"k0a7d2d1e": "Preis/Kapsel (brutto):",
|
||||||
|
"k9fb4721a": "x je Mitglied",
|
||||||
|
"k65ad80b0": "ID:",
|
||||||
|
"k22a3f7c1": "Insgesamt verdient",
|
||||||
|
"k69adf332": "Anteil",
|
||||||
|
"k5b2c4431": "Name",
|
||||||
|
"kb1438ed0": "E-Mail",
|
||||||
|
"k18fd92a1": "Wird entfernt...",
|
||||||
|
"k7c40d832": "{label} wird aus dem Pool entfernt.",
|
||||||
|
"k74122df0": "dieser Nutzer",
|
||||||
|
"k2ee90f41": "Entfernen",
|
||||||
|
"k79d12c2e": "Wird geladen...",
|
||||||
|
"kf5d7b213": "Suche läuft...",
|
||||||
|
"k76f12c8a": "Zurücksetzen",
|
||||||
|
"k8c011ed3": "Hinzufügen",
|
||||||
|
"k0f13bc22": "Fertig",
|
||||||
|
"k89bc3412": "Wird hinzugefügt...",
|
||||||
|
"k7e44aa19": "Auswahl hinzufügen",
|
||||||
|
"k2042d9f2": "Keine Nutzer ausgewählt",
|
||||||
|
"k3ab09ef0": "{count} ausgewählt",
|
||||||
|
"k40b5c1d2": "Beschreibung",
|
||||||
|
"k8ef02c19": "Preis pro Kapsel (netto)",
|
||||||
|
"ka320df81": "Sonstiges",
|
||||||
|
"k2f9cd1e0": "Kaffee",
|
||||||
|
"kb8d70f41": "Kein Abonnement ausgewählt (später festlegen)",
|
||||||
|
"kf9d2e4a0": "Pool erstellen",
|
||||||
|
"k241a2d77": "Wird erstellt...",
|
||||||
|
"k612fc0a4": "Zurücksetzen",
|
||||||
"k21db276a": "Auf Lager",
|
"k21db276a": "Auf Lager",
|
||||||
"k228929e2": "Profilinformationen",
|
"k228929e2": "Profilinformationen",
|
||||||
"k23c9f0ff": "Noch keine Ergebnisse. Importieren Sie einen SQL-Dump, um die Ausgabe zu sehen.",
|
"k23c9f0ff": "Noch keine Ergebnisse. Importieren Sie einen SQL-Dump, um die Ausgabe zu sehen.",
|
||||||
@ -1316,6 +1373,88 @@ export const de: Translations = {
|
|||||||
"k2f176a63": "Noch keine Newsartikel verfügbar.",
|
"k2f176a63": "Noch keine Newsartikel verfügbar.",
|
||||||
"k2f4ebc32": "Zeilen pro Seite:",
|
"k2f4ebc32": "Zeilen pro Seite:",
|
||||||
"k2f78fabe": "Zur Nutzerverifizierung",
|
"k2f78fabe": "Zur Nutzerverifizierung",
|
||||||
|
"k6430ec9d": "Export {format} (Dummy) für {count} Rechnungen",
|
||||||
|
"k4d551f20": "Prüfung fehlgeschlagen ({status})",
|
||||||
|
"k84447f0f": "Netzwerkfehler",
|
||||||
|
"k01a04b9d": "PDF konnte nicht geladen werden ({status})",
|
||||||
|
"k6d4dfb53": "Rechnungs-PDF konnte nicht geladen werden.",
|
||||||
|
"k0f7cd409": "Gesamtbetrag brutto ist erforderlich.",
|
||||||
|
"kecf550b9": "Upload fehlgeschlagen ({status})",
|
||||||
|
"k28165f23": "Rechnung {invoice} wurde erfolgreich erstellt.",
|
||||||
|
"k1e7317ac": "Upload fehlgeschlagen.",
|
||||||
|
"k5a2f88b8": "Anfrage fehlgeschlagen ({status})",
|
||||||
|
"k5f4036ad": "Bericht wurde an {email} gesendet ({count} bezahlte Rechnung(en)).",
|
||||||
|
"kfbe29d11": "PDF ansehen",
|
||||||
|
"kf67200af": "Details",
|
||||||
|
"k8070cd52": "Finanzoperationen",
|
||||||
|
"k73f7184d": "Gesamtumsatz (gesamt)",
|
||||||
|
"k9b3082af": "Umsatz (Zeitraum)",
|
||||||
|
"k9f4ec5e2": "Rechnungen (Zeitraum)",
|
||||||
|
"kafb65833": "Zeitraum",
|
||||||
|
"k0f5d95a1": "Lfd. Jahr",
|
||||||
|
"k5ce7a5b0": "Live-Daten aus dem Backend; Bearbeitung auf einer separaten Seite.",
|
||||||
|
"k3e4a95bc": "Aktive Länder: {count} • Beispiele: {examples}",
|
||||||
|
"k21f123af": "Rechnungen",
|
||||||
|
"kddf7ca98": "Neu laden",
|
||||||
|
"k8bb2fe26": "Suchen (Rechnungsnr., Kunde)",
|
||||||
|
"kec99a6cc": "Status: Alle",
|
||||||
|
"k5f6d9f11": "Entwurf",
|
||||||
|
"kdc8f2ab2": "Ausgestellt",
|
||||||
|
"k9d5b2d74": "Bezahlt",
|
||||||
|
"k2f44ec11": "Überfällig",
|
||||||
|
"kcf31ed66": "Storniert",
|
||||||
|
"kf6a5a971": "Pool-Zufluss-Diagnose für Rechnung #{invoice}",
|
||||||
|
"kaf7e90cc": "OK",
|
||||||
|
"k6ba7f5b1": "Blockiert",
|
||||||
|
"kf1b73a92": "Pool",
|
||||||
|
"k1ddc3f42": "Kapseln",
|
||||||
|
"kdb79aa30": "Betrag (brutto)",
|
||||||
|
"k93e61ad1": "Gebucht",
|
||||||
|
"kf8f0c1f3": "Rechnung",
|
||||||
|
"k762eef76": "Betrag",
|
||||||
|
"k0afbbac4": "Aktionen",
|
||||||
|
"kba8ee9b1": "Straße",
|
||||||
|
"k5d52917f": "Stadt",
|
||||||
|
"k9e39e560": "Land",
|
||||||
|
"k57d5f250": "MwSt.-Satz (%)",
|
||||||
|
"k1f5a403a": "Netto (berechnet)",
|
||||||
|
"k089e8c08": "MwSt. (berechnet)",
|
||||||
|
"k3bc9a0f1": "Wird hochgeladen...",
|
||||||
|
"k1139753d": "Rechnung erstellen",
|
||||||
|
"k45c3fd51": "Nur {paid} Rechnungen werden in den Bericht aufgenommen, unabhängig vom Statusfilter.",
|
||||||
|
"kdd22a5f2": "Der aktuelle Datumsfilter ({from} – {to}) wird angewendet.",
|
||||||
|
"k795911e8": "Wird gesendet...",
|
||||||
|
"kf6f9b3c0": "Bericht senden",
|
||||||
|
"kfd6e0974": "Wird geladen...",
|
||||||
|
"k6f4e16a2": "Benutzerverwaltung",
|
||||||
|
"k20a59c89": "Benutzerübersicht",
|
||||||
|
"k107562d0": "Admins",
|
||||||
|
"k1da4ef38": "Gäste",
|
||||||
|
"k03f9899f": "Admin",
|
||||||
|
"kdcdca454": "Gast",
|
||||||
|
"kf9463361": "Privat",
|
||||||
|
"k7eedf98b": "Firma",
|
||||||
|
"kf1882b08": "Privat",
|
||||||
|
"k56f0ef1f": "Firma",
|
||||||
|
"kf6afbb1f": "Aktiv",
|
||||||
|
"k8f278f58": "Ausstehend",
|
||||||
|
"k91a76444": "Suche",
|
||||||
|
"k3ae7a0c0": "Filtern",
|
||||||
|
"k2e41c8dc": "Zeige {current} von {total} Benutzern",
|
||||||
|
"k91f49568": "Benutzer",
|
||||||
|
"kec4fe9ef": "Typ",
|
||||||
|
"ked760737": "Rolle",
|
||||||
|
"kf123704b": "Erstellt",
|
||||||
|
"k18bf2a04": "Suspendiert",
|
||||||
|
"k9129ea6f": "Archiviert",
|
||||||
|
"k2fc06d90": "Inaktiv",
|
||||||
|
"k6f3cf5f5": "Unbekannt",
|
||||||
|
"k835f1c86": "Unbekannte Firma",
|
||||||
|
"k76870ea8": "Unbekannt",
|
||||||
|
"k2bf5e6ec": "Benutzer",
|
||||||
|
"k768f3f4a": "Nie",
|
||||||
|
"k9f8d7a4f": "Bearbeiten",
|
||||||
|
"kf03c39b7": "Seite {page} von {pages} ({total} Benutzer gesamt)",
|
||||||
"k31cadca6": "Partnername *",
|
"k31cadca6": "Partnername *",
|
||||||
"k31d46514": "Wurzelknoten:",
|
"k31d46514": "Wurzelknoten:",
|
||||||
"k33918465": "Unternehmensname",
|
"k33918465": "Unternehmensname",
|
||||||
@ -1774,8 +1913,43 @@ export const de: Translations = {
|
|||||||
"k77d01d6a": "Offen ▾",
|
"k77d01d6a": "Offen ▾",
|
||||||
"k835d3cbf": "Warten aufs erste Bootstrap-Event",
|
"k835d3cbf": "Warten aufs erste Bootstrap-Event",
|
||||||
"k8de6d3df": "EN ref",
|
"k8de6d3df": "EN ref",
|
||||||
|
"k09546aee": "Verwaltungs-Admin",
|
||||||
|
"k2c0cfef4": "Beschreibungsschluessel",
|
||||||
|
"k339260c9": "Titel-Fallback",
|
||||||
"kb494ddd8": "Nach oben Scrollen",
|
"kb494ddd8": "Nach oben Scrollen",
|
||||||
"kcfb5fb54": "Klicken zum Panel öffnen"
|
"kcfb5fb54": "Klicken zum Panel öffnen",
|
||||||
|
"k4a292bef": "Beschreibung Fallback",
|
||||||
|
"k68e73120": "Fehlende Schluessel werden in en.ts hinzugefuegt und die Scan-Ergebnisse werden aktualisiert...",
|
||||||
|
"kbd3f0f44": "Fehlende Schluessel hinzufuegen",
|
||||||
|
"kd96b6952": "Titel-Key",
|
||||||
|
"k1e4d7a90": "Wird geladen...",
|
||||||
|
"k5b2c8d67": "Benutzerverifizierung",
|
||||||
|
"k3f4f2b01": "Suche",
|
||||||
|
"k8f1a2c34": "Rolle",
|
||||||
|
"k7e2d9a10": "Status",
|
||||||
|
"k8b2f1c77": "Privat",
|
||||||
|
"k6c3d4e55": "Firma",
|
||||||
|
"k9d0a7b42": "Benutzer",
|
||||||
|
"k2a6c8d90": "Admin",
|
||||||
|
"k1b3d5f78": "Ausstehend",
|
||||||
|
"k4c7e9a21": "Aktiv",
|
||||||
|
"k5d8a1c63": "Bereit zur Verifizierung",
|
||||||
|
"k7a4e2b19": "Schritte",
|
||||||
|
"k0f2c9d31": "Zeige",
|
||||||
|
"k6e1b3a44": "von",
|
||||||
|
"k3b8d2f75": "Benutzern",
|
||||||
|
"k7c1e5b40": "Benutzer",
|
||||||
|
"k8d4a2f16": "Typ",
|
||||||
|
"k3e9b6c12": "Fortschritt",
|
||||||
|
"k9a5c1e68": "Erstellt",
|
||||||
|
"k2f6d9a33": "Aktionen",
|
||||||
|
"k2d7f4a81": "Unbekannte Firma",
|
||||||
|
"k9f3a1e74": "Unbekannt",
|
||||||
|
"k1c7b4e52": "Ansehen",
|
||||||
|
"k4e2a8d19": "Seite",
|
||||||
|
"k5a9d3c27": "ausstehende Benutzer",
|
||||||
|
"k1f8c4a52": "Zeige {current} von {total} Benutzern",
|
||||||
|
"k9b5d2e70": "Seite {page} von {totalPages} ({total} ausstehende Benutzer)"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"loginSuccess": "Anmeldung erfolgreich",
|
"loginSuccess": "Anmeldung erfolgreich",
|
||||||
|
|||||||
@ -1298,6 +1298,63 @@ export const en: Translations = {
|
|||||||
"k209ba561": "Create New Pool",
|
"k209ba561": "Create New Pool",
|
||||||
"k20ab2fc7": "We'll send a verification code to your email address.",
|
"k20ab2fc7": "We'll send a verification code to your email address.",
|
||||||
"k21440f8a": "Pool Management",
|
"k21440f8a": "Pool Management",
|
||||||
|
"k6f7f26a1": "Pool Admin",
|
||||||
|
"k5f4d2c11": "{count} total",
|
||||||
|
"ke2a1b003": "Inactive",
|
||||||
|
"k3bc84f12": "Active",
|
||||||
|
"kfd227aa9": "Members",
|
||||||
|
"k91c8d444": "Created",
|
||||||
|
"k7d2a1190": "Manage",
|
||||||
|
"kf3b0c221": "Archive",
|
||||||
|
"k1e2f3a44": "No inactive pools found.",
|
||||||
|
"ka8b3c104": "No active pools found.",
|
||||||
|
"k0b84e6aa": "Archive pool?",
|
||||||
|
"k9ad214be": "Activate pool?",
|
||||||
|
"k3fe81c2a": "Users will no longer be able to join or use this pool while archived.",
|
||||||
|
"k1d6c33f1": "This pool will be active again and available for use.",
|
||||||
|
"k8fa13d9b": "Set Active",
|
||||||
|
"k1a0d4f73": "Failed to deactivate pool.",
|
||||||
|
"k54a977c3": "Failed to activate pool.",
|
||||||
|
"k78dc5a11": "Unnamed Pool",
|
||||||
|
"k8dca3321": "Unnamed user",
|
||||||
|
"k7021ad54": "Failed to load pool members.",
|
||||||
|
"k53f7e9a1": "Authentication required.",
|
||||||
|
"k9c4d2ab3": "Failed to search users.",
|
||||||
|
"k3f7ca220": "Failed to add user.",
|
||||||
|
"k90b5f8d1": "Failed to add users.",
|
||||||
|
"k296db6a0": "Failed to remove user from pool.",
|
||||||
|
"kf0c9a38d": "Manage users and track pool funds",
|
||||||
|
"kcf73e90d": "Core pool for shared capsule costs across all members.",
|
||||||
|
"k20f6ac90": "Pool for coffee subscription inflows and member capsule usage.",
|
||||||
|
"k4bc91d55": "General pool for additional subscriptions and manual allocations.",
|
||||||
|
"k0a7d2d1e": "Price/capsule (gross):",
|
||||||
|
"k9fb4721a": "x each member",
|
||||||
|
"k65ad80b0": "ID:",
|
||||||
|
"k22a3f7c1": "Total Earned",
|
||||||
|
"k69adf332": "Share",
|
||||||
|
"k5b2c4431": "Name",
|
||||||
|
"kb1438ed0": "Email",
|
||||||
|
"k18fd92a1": "Removing...",
|
||||||
|
"k7c40d832": "This will remove {label} from the pool.",
|
||||||
|
"k74122df0": "this user",
|
||||||
|
"k2ee90f41": "Remove",
|
||||||
|
"k79d12c2e": "Loading...",
|
||||||
|
"kf5d7b213": "Searching...",
|
||||||
|
"k76f12c8a": "Clear",
|
||||||
|
"k8c011ed3": "Add",
|
||||||
|
"k0f13bc22": "Done",
|
||||||
|
"k89bc3412": "Adding...",
|
||||||
|
"k7e44aa19": "Add Selected",
|
||||||
|
"k2042d9f2": "No users selected",
|
||||||
|
"k3ab09ef0": "{count} selected",
|
||||||
|
"k40b5c1d2": "Description",
|
||||||
|
"k8ef02c19": "Price per capsule (net)",
|
||||||
|
"ka320df81": "Other",
|
||||||
|
"k2f9cd1e0": "Coffee",
|
||||||
|
"kb8d70f41": "No subscription selected (set later)",
|
||||||
|
"kf9d2e4a0": "Create Pool",
|
||||||
|
"k241a2d77": "Creating...",
|
||||||
|
"k612fc0a4": "Reset",
|
||||||
"k21db276a": "In Stock",
|
"k21db276a": "In Stock",
|
||||||
"k228929e2": "Profile Information",
|
"k228929e2": "Profile Information",
|
||||||
"k23c9f0ff": "No results yet. Import a SQL dump to see output.",
|
"k23c9f0ff": "No results yet. Import a SQL dump to see output.",
|
||||||
@ -1316,6 +1373,88 @@ export const en: Translations = {
|
|||||||
"k2f176a63": "No news articles available yet.",
|
"k2f176a63": "No news articles available yet.",
|
||||||
"k2f4ebc32": "Rows per page:",
|
"k2f4ebc32": "Rows per page:",
|
||||||
"k2f78fabe": "Go to User Verification",
|
"k2f78fabe": "Go to User Verification",
|
||||||
|
"k6430ec9d": "Export {format} (dummy) for {count} invoices",
|
||||||
|
"k4d551f20": "Check failed ({status})",
|
||||||
|
"k84447f0f": "Network error",
|
||||||
|
"k01a04b9d": "Failed to load PDF ({status})",
|
||||||
|
"k6d4dfb53": "Failed to load invoice PDF.",
|
||||||
|
"k0f7cd409": "Total gross is required.",
|
||||||
|
"kecf550b9": "Upload failed ({status})",
|
||||||
|
"k28165f23": "Invoice {invoice} created successfully.",
|
||||||
|
"k1e7317ac": "Upload failed.",
|
||||||
|
"k5a2f88b8": "Request failed ({status})",
|
||||||
|
"k5f4036ad": "Report sent to {email} ({count} paid invoice(s)).",
|
||||||
|
"kfbe29d11": "View PDF",
|
||||||
|
"kf67200af": "Details",
|
||||||
|
"k8070cd52": "Financial Operations",
|
||||||
|
"k73f7184d": "Total revenue (all time)",
|
||||||
|
"k9b3082af": "Revenue (range)",
|
||||||
|
"k9f4ec5e2": "Invoices (range)",
|
||||||
|
"kafb65833": "Timeframe",
|
||||||
|
"k0f5d95a1": "YTD",
|
||||||
|
"k5ce7a5b0": "Live data from backend; edit on a separate page.",
|
||||||
|
"k3e4a95bc": "Active countries: {count} • Examples: {examples}",
|
||||||
|
"k21f123af": "Invoices",
|
||||||
|
"kddf7ca98": "Reload",
|
||||||
|
"k8bb2fe26": "Search (invoice no., customer)",
|
||||||
|
"kec99a6cc": "Status: All",
|
||||||
|
"k5f6d9f11": "Draft",
|
||||||
|
"kdc8f2ab2": "Issued",
|
||||||
|
"k9d5b2d74": "Paid",
|
||||||
|
"k2f44ec11": "Overdue",
|
||||||
|
"kcf31ed66": "Cancelled",
|
||||||
|
"kf6a5a971": "Pool inflow diagnostic for invoice #{invoice}",
|
||||||
|
"kaf7e90cc": "OK",
|
||||||
|
"k6ba7f5b1": "Blocked",
|
||||||
|
"kf1b73a92": "Pool",
|
||||||
|
"k1ddc3f42": "Capsules",
|
||||||
|
"kdb79aa30": "Amount (gross)",
|
||||||
|
"k93e61ad1": "Booked",
|
||||||
|
"kf8f0c1f3": "Invoice",
|
||||||
|
"k762eef76": "Amount",
|
||||||
|
"k0afbbac4": "Actions",
|
||||||
|
"kba8ee9b1": "Street",
|
||||||
|
"k5d52917f": "City",
|
||||||
|
"k9e39e560": "Country",
|
||||||
|
"k57d5f250": "VAT Rate (%)",
|
||||||
|
"k1f5a403a": "Net (calculated)",
|
||||||
|
"k089e8c08": "VAT (calculated)",
|
||||||
|
"k3bc9a0f1": "Uploading...",
|
||||||
|
"k1139753d": "Create Invoice",
|
||||||
|
"k45c3fd51": "Only {paid} invoices will be included in the report, regardless of the status filter.",
|
||||||
|
"kdd22a5f2": "The current date range filter ({from} – {to}) will be applied.",
|
||||||
|
"k795911e8": "Sending...",
|
||||||
|
"kf6f9b3c0": "Send Report",
|
||||||
|
"kfd6e0974": "Loading...",
|
||||||
|
"k6f4e16a2": "User Administration",
|
||||||
|
"k20a59c89": "User Overview",
|
||||||
|
"k107562d0": "Admins",
|
||||||
|
"k1da4ef38": "Guests",
|
||||||
|
"k03f9899f": "Admin",
|
||||||
|
"kdcdca454": "Guest",
|
||||||
|
"kf9463361": "Personal",
|
||||||
|
"k7eedf98b": "Company",
|
||||||
|
"kf1882b08": "Personal",
|
||||||
|
"k56f0ef1f": "Company",
|
||||||
|
"kf6afbb1f": "Active",
|
||||||
|
"k8f278f58": "Pending",
|
||||||
|
"k91a76444": "Search",
|
||||||
|
"k3ae7a0c0": "Filter",
|
||||||
|
"k2e41c8dc": "Showing {current} of {total} users",
|
||||||
|
"k91f49568": "User",
|
||||||
|
"kec4fe9ef": "Type",
|
||||||
|
"ked760737": "Role",
|
||||||
|
"kf123704b": "Created",
|
||||||
|
"k18bf2a04": "Suspended",
|
||||||
|
"k9129ea6f": "Archived",
|
||||||
|
"k2fc06d90": "Inactive",
|
||||||
|
"k6f3cf5f5": "Unknown",
|
||||||
|
"k835f1c86": "Unknown Company",
|
||||||
|
"k76870ea8": "Unknown",
|
||||||
|
"k2bf5e6ec": "User",
|
||||||
|
"k768f3f4a": "Never",
|
||||||
|
"k9f8d7a4f": "Edit",
|
||||||
|
"kf03c39b7": "Page {page} of {pages} ({total} total users)",
|
||||||
"k31cadca6": "Partner Name *",
|
"k31cadca6": "Partner Name *",
|
||||||
"k31d46514": "Top node:",
|
"k31d46514": "Top node:",
|
||||||
"k33918465": "Company Name",
|
"k33918465": "Company Name",
|
||||||
@ -1774,8 +1913,43 @@ export const en: Translations = {
|
|||||||
"k77d01d6a": "Open ▾",
|
"k77d01d6a": "Open ▾",
|
||||||
"k835d3cbf": "Waiting for first bootstrap event...",
|
"k835d3cbf": "Waiting for first bootstrap event...",
|
||||||
"k8de6d3df": "EN ref",
|
"k8de6d3df": "EN ref",
|
||||||
|
"k09546aee": "Dashboard Admin",
|
||||||
|
"k2c0cfef4": "Description Key",
|
||||||
|
"k339260c9": "Title Fallback",
|
||||||
"kb494ddd8": "Scroll to top",
|
"kb494ddd8": "Scroll to top",
|
||||||
"kcfb5fb54": "Click to open"
|
"kcfb5fb54": "Click to open",
|
||||||
|
"k4a292bef": "Description Fallback",
|
||||||
|
"k68e73120": "Adding missing keys to en.ts and refreshing scan results...",
|
||||||
|
"kbd3f0f44": "Add missing keys",
|
||||||
|
"kd96b6952": "Title Key",
|
||||||
|
"k1e4d7a90": "Loading...",
|
||||||
|
"k5b2c8d67": "User Verification",
|
||||||
|
"k3f4f2b01": "Search",
|
||||||
|
"k8f1a2c34": "Role",
|
||||||
|
"k7e2d9a10": "Status",
|
||||||
|
"k8b2f1c77": "Personal",
|
||||||
|
"k6c3d4e55": "Company",
|
||||||
|
"k9d0a7b42": "User",
|
||||||
|
"k2a6c8d90": "Admin",
|
||||||
|
"k1b3d5f78": "Pending",
|
||||||
|
"k4c7e9a21": "Active",
|
||||||
|
"k5d8a1c63": "Ready to Verify",
|
||||||
|
"k7a4e2b19": "Steps",
|
||||||
|
"k0f2c9d31": "Showing",
|
||||||
|
"k6e1b3a44": "of",
|
||||||
|
"k3b8d2f75": "users",
|
||||||
|
"k7c1e5b40": "User",
|
||||||
|
"k8d4a2f16": "Type",
|
||||||
|
"k3e9b6c12": "Progress",
|
||||||
|
"k9a5c1e68": "Created",
|
||||||
|
"k2f6d9a33": "Actions",
|
||||||
|
"k2d7f4a81": "Unknown Company",
|
||||||
|
"k9f3a1e74": "Unknown",
|
||||||
|
"k1c7b4e52": "View",
|
||||||
|
"k4e2a8d19": "Page",
|
||||||
|
"k5a9d3c27": "pending users",
|
||||||
|
"k1f8c4a52": "Showing {current} of {total} users",
|
||||||
|
"k9b5d2e70": "Page {page} of {totalPages} ({total} pending users)"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"loginSuccess": "Login successful",
|
"loginSuccess": "Login successful",
|
||||||
|
|||||||
@ -21,6 +21,8 @@ const builtInTranslations: Record<string, Record<string, unknown>> = {
|
|||||||
de: de as unknown as Record<string, unknown>,
|
de: de as unknown as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const APP_LANGUAGE_STORAGE_KEY = 'pp-selected-language';
|
||||||
|
|
||||||
// Flat map of English keys used as canonical key list and fallback
|
// Flat map of English keys used as canonical key list and fallback
|
||||||
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
|
const enFlat = flattenObject(en as unknown as Record<string, unknown>);
|
||||||
|
|
||||||
@ -51,7 +53,11 @@ interface I18nProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function I18nProvider({ children }: I18nProviderProps) {
|
export function I18nProvider({ children }: I18nProviderProps) {
|
||||||
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
|
const [language, setLanguage] = useState<string>(() => {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_LANGUAGE;
|
||||||
|
const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY);
|
||||||
|
return stored && stored.trim() ? stored : DEFAULT_LANGUAGE;
|
||||||
|
});
|
||||||
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
|
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
|
||||||
languages: [
|
languages: [
|
||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English' },
|
||||||
@ -104,6 +110,11 @@ export function I18nProvider({ children }: I18nProviderProps) {
|
|||||||
setLanguage(fallback);
|
setLanguage(fallback);
|
||||||
}, [translationFiles.languages, language]);
|
}, [translationFiles.languages, language]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language);
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
const t = useCallback((key: string): string => {
|
const t = useCallback((key: string): string => {
|
||||||
// 1. Check translation loaded from translation files API.
|
// 1. Check translation loaded from translation files API.
|
||||||
const fileValue = translationFiles.translations[language]?.[key];
|
const fileValue = translationFiles.translations[language]?.[key];
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import type { ComponentType, SVGProps } from 'react';
|
import type { ComponentType, SVGProps } from 'react';
|
||||||
import type { DashboardPlatformIconName } from './utils/dashboardPlatforms';
|
import type { DashboardPlatformIconName } from './utils/dashboardPlatforms';
|
||||||
|
import LanguageSwitcher from './components/LanguageSwitcher';
|
||||||
|
|
||||||
import { useTranslation } from './i18n/useTranslation';
|
import { useTranslation } from './i18n/useTranslation';
|
||||||
|
|
||||||
@ -92,9 +93,14 @@ export default function HomePage() {
|
|||||||
className="h-16 w-16 sm:h-20 sm:w-20 object-contain"
|
className="h-16 w-16 sm:h-20 sm:w-20 object-contain"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="inline-flex items-center rounded-full border border-gray-200 bg-white/60 px-3 py-1 text-xs font-semibold text-gray-700">
|
<div className="inline-flex items-center rounded-full border border-gray-200 bg-white/60 px-3 py-1 text-xs font-semibold text-gray-700">
|
||||||
Welcome
|
Welcome
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shrink-0">
|
||||||
|
<LanguageSwitcher variant="light" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<h1 className="mt-3 text-5xl sm:text-6xl md:text-7xl font-black tracking-tight leading-none text-transparent bg-clip-text bg-gradient-to-r from-gray-900 via-gray-700 to-amber-700">{t('autofix.k788633d1')}</h1>
|
<h1 className="mt-3 text-5xl sm:text-6xl md:text-7xl font-black tracking-tight leading-none text-transparent bg-clip-text bg-gradient-to-r from-gray-900 via-gray-700 to-amber-700">{t('autofix.k788633d1')}</h1>
|
||||||
<p className="mt-3 text-sm sm:text-base text-gray-700">{t('autofix.kde5c689e')}</p>
|
<p className="mt-3 text-sm sm:text-base text-gray-700">{t('autofix.kde5c689e')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { PROFILE_TOKENS } from './styleTokens'
|
||||||
|
|
||||||
export default function BankInformation({
|
export default function BankInformation({
|
||||||
profileData,
|
profileData,
|
||||||
@ -28,35 +27,35 @@ export default function BankInformation({
|
|||||||
const iban = profileData.iban || ''
|
const iban = profileData.iban || ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k5d4f6b2f')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k5d4f6b2f')}</h2>
|
||||||
<span className="text-xs text-gray-500">{t('autofix.kfc6b6a29')}</span>
|
<span className="text-xs text-slate-500 break-words">{t('autofix.kfc6b6a29')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kada9d61c')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.kada9d61c')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
className={PROFILE_TOKENS.input}
|
||||||
value={accountHolder}
|
value={accountHolder}
|
||||||
disabled
|
disabled
|
||||||
placeholder={t('autofix.kf2147f07')}
|
placeholder={t('autofix.kf2147f07')}
|
||||||
/>
|
/>
|
||||||
{!accountHolder && <div className="mt-1 text-sm italic text-gray-400">{t('autofix.kf2147f07')}</div>}
|
{!accountHolder && <div className="mt-1 text-sm italic text-slate-400 break-words">{t('autofix.kf2147f07')}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">IBAN</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 border border-white/60 rounded-lg bg-white/50 text-gray-900"
|
className={PROFILE_TOKENS.input}
|
||||||
value={iban}
|
value={iban}
|
||||||
disabled
|
disabled
|
||||||
placeholder={t('autofix.kf2147f07')}
|
placeholder={t('autofix.kf2147f07')}
|
||||||
/>
|
/>
|
||||||
{!iban && <div className="mt-1 text-sm italic text-gray-400">{t('autofix.kf2147f07')}</div>}
|
{!iban && <div className="mt-1 text-sm italic text-slate-400 break-words">{t('autofix.kf2147f07')}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { UserCircleIcon, EnvelopeIcon, PhoneIcon, MapPinIcon, PencilIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
|
import { UserCircleIcon, EnvelopeIcon, PhoneIcon, MapPinIcon, PencilIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
|
||||||
|
import { PROFILE_TOKENS } from './styleTokens'
|
||||||
|
|
||||||
export default function BasicInformation({ profileData, HighlightIfMissing, onEdit }: {
|
export default function BasicInformation({ profileData, HighlightIfMissing, onEdit }: {
|
||||||
profileData: any,
|
profileData: any,
|
||||||
@ -13,11 +12,11 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between gap-3 mb-6 flex-wrap">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k1d178b73')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k1d178b73')}</h2>
|
||||||
<button
|
<button
|
||||||
className="flex items-center text-[#8D6B1D] hover:text-[#7A5E1A] transition-colors"
|
className="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-[#8D6B1D] hover:text-[#7A5E1A] hover:bg-slate-50 transition-colors"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
>
|
>
|
||||||
<PencilIcon className="h-4 w-4 mr-1" />
|
<PencilIcon className="h-4 w-4 mr-1" />
|
||||||
@ -28,20 +27,20 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
{profileData.userType === 'personal' && (
|
{profileData.userType === 'personal' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kfe8083f8')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.kfe8083f8')}</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.firstName}>
|
<HighlightIfMissing value={profileData.firstName}>
|
||||||
<span className="text-gray-900">{profileData.firstName}</span>
|
<span className="text-slate-900 break-words">{profileData.firstName}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k6a4108c8')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.k6a4108c8')}</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.lastName}>
|
<HighlightIfMissing value={profileData.lastName}>
|
||||||
<span className="text-gray-900">{profileData.lastName}</span>
|
<span className="text-slate-900 break-words">{profileData.lastName}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -49,42 +48,42 @@ export default function BasicInformation({ profileData, HighlightIfMissing, onEd
|
|||||||
)}
|
)}
|
||||||
{profileData.userType === 'company' && (
|
{profileData.userType === 'company' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k9dafde30')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.k9dafde30')}</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<UserCircleIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.contactPersonName}>
|
<HighlightIfMissing value={profileData.contactPersonName}>
|
||||||
<span className="text-gray-900">{profileData.contactPersonName}</span>
|
<span className="text-slate-900 break-words">{profileData.contactPersonName}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.kde6d477f')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.kde6d477f')}</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<EnvelopeIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.email}>
|
<HighlightIfMissing value={profileData.email}>
|
||||||
<span className="text-gray-900">{profileData.email}</span>
|
<span className="text-slate-900 break-all">{profileData.email}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
<CheckCircleIcon className="h-5 w-5 text-green-500 ml-auto" />
|
<CheckCircleIcon className="h-5 w-5 text-green-500 ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('autofix.k2a2fe15a')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('autofix.k2a2fe15a')}</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<PhoneIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.phone}>
|
<HighlightIfMissing value={profileData.phone}>
|
||||||
<span className="text-gray-900">{profileData.phone}</span>
|
<span className="text-slate-900 break-words">{profileData.phone}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">
|
||||||
Address
|
Address
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center p-3 bg-white/50 border border-white/60 rounded-lg">
|
<div className="flex items-center p-3 bg-white/70 border border-white/70 rounded-2xl">
|
||||||
<MapPinIcon className="h-5 w-5 text-gray-400 mr-3" />
|
<MapPinIcon className="h-5 w-5 text-gray-400 mr-3" />
|
||||||
<HighlightIfMissing value={profileData.address}>
|
<HighlightIfMissing value={profileData.address}>
|
||||||
<span className="text-gray-900">{profileData.address}</span>
|
<span className="text-slate-900 break-words">{profileData.address}</span>
|
||||||
</HighlightIfMissing>
|
</HighlightIfMissing>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react'
|
import React, { useEffect, useState, useRef } from 'react'
|
||||||
|
import { PROFILE_TOKENS } from './styleTokens'
|
||||||
|
|
||||||
export default function EditModal({
|
export default function EditModal({
|
||||||
open,
|
open,
|
||||||
@ -85,22 +86,22 @@ export default function EditModal({
|
|||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-white/40 backdrop-blur-md transition-opacity duration-200 ${
|
className={`${PROFILE_TOKENS.modalOverlay} ${
|
||||||
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
open ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg shadow-lg p-4 sm:p-6 w-[calc(100%-2rem)] max-w-md max-h-[85dvh] overflow-y-auto transform transition-all duration-200 ${
|
className={`${PROFILE_TOKENS.modalPanel} ${
|
||||||
open ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
|
open ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'rgba(255,255,255,0.78)',
|
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||||
backdropFilter: 'blur(14px)',
|
backdropFilter: 'blur(14px)',
|
||||||
WebkitBackdropFilter: 'blur(14px)',
|
WebkitBackdropFilter: 'blur(14px)',
|
||||||
border: '1px solid rgba(255,255,255,0.55)',
|
border: '1px solid rgba(255,255,255,0.8)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-semibold mb-4 text-gray-900">
|
<h2 className="text-xl font-semibold mb-4 text-slate-900 break-words">
|
||||||
Edit {type === 'basic' ? 'Basic Information' : 'Bank Information'}
|
Edit {type === 'basic' ? 'Basic Information' : 'Bank Information'}
|
||||||
</h2>
|
</h2>
|
||||||
{children}
|
{children}
|
||||||
@ -113,10 +114,10 @@ export default function EditModal({
|
|||||||
>
|
>
|
||||||
{fields.map(field => (
|
{fields.map(field => (
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{field.label}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{field.label}</label>
|
||||||
<input
|
<input
|
||||||
type={field.type || 'text'}
|
type={field.type || 'text'}
|
||||||
className="w-full p-2 border border-gray-200 rounded-lg bg-gray-50 text-gray-900"
|
className={PROFILE_TOKENS.modalInput}
|
||||||
value={values[field.key] ?? ''}
|
value={values[field.key] ?? ''}
|
||||||
onChange={e => onChange(field.key, e.target.value)}
|
onChange={e => onChange(field.key, e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -125,13 +126,13 @@ export default function EditModal({
|
|||||||
<div className="flex gap-2 mt-4">
|
<div className="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-[#8D6B1D] rounded-lg hover:bg-[#7A5E1A] transition"
|
className={PROFILE_TOKENS.modalPrimaryButton}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
|
className={PROFILE_TOKENS.modalSecondaryButton}
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@ -1,41 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { PROFILE_TOKENS } from './styleTokens'
|
||||||
|
|
||||||
export default function MediaSection({ documents }: { documents: any[] }) {
|
export default function MediaSection({ documents }: { documents: any[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
const hasDocuments = Array.isArray(documents) && documents.length > 0;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">{t('autofix.ked7d533b')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900 mb-4 break-words">{t('autofix.ked7d533b')}</h2>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
{hasDocuments ? (
|
{hasDocuments ? (
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
<thead>
|
<thead className="bg-slate-50/70">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
<th className={PROFILE_TOKENS.tableHead}>Name</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
<th className={PROFILE_TOKENS.tableHead}>Type</th>
|
||||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Uploaded</th>
|
<th className={PROFILE_TOKENS.tableHead}>Uploaded</th>
|
||||||
<th className="px-4 py-2"></th>
|
<th className="px-4 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-slate-100">
|
||||||
{documents.map(doc => (
|
{documents.map(doc => (
|
||||||
<tr key={doc.id} className="bg-white hover:bg-gray-50">
|
<tr key={doc.id} className="bg-white/70 hover:bg-slate-50/70">
|
||||||
<td className="px-4 py-2 text-gray-900">{doc.name}</td>
|
<td className="px-4 py-2 text-slate-900 break-words">{doc.name}</td>
|
||||||
<td className="px-4 py-2 text-gray-600">{doc.type}</td>
|
<td className="px-4 py-2 text-slate-600 break-words">{doc.type}</td>
|
||||||
<td className="px-4 py-2 text-gray-600">{doc.uploaded}</td>
|
<td className="px-4 py-2 text-slate-600 break-words">{doc.uploaded}</td>
|
||||||
<td className="px-4 py-2 flex gap-2">
|
<td className="px-4 py-2 flex flex-wrap gap-2">
|
||||||
{doc.signedUrl ? (
|
{doc.signedUrl ? (
|
||||||
<>
|
<>
|
||||||
<a href={doc.signedUrl} download className="px-3 py-1 text-xs bg-[#8D6B1D] text-white rounded hover:bg-[#7A5E1A] transition">Download</a>
|
<a href={doc.signedUrl} download className="px-3 py-1 text-xs font-medium bg-[#8D6B1D] text-white rounded-2xl hover:bg-[#7A5E1A] transition">Download</a>
|
||||||
<a href={doc.signedUrl} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-xs bg-gray-200 text-gray-700 rounded hover:bg-gray-300 transition">Preview</a>
|
<a href={doc.signedUrl} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-xs font-medium bg-slate-200 text-slate-700 rounded-2xl hover:bg-slate-300 transition">Preview</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-gray-400 italic">{t('autofix.kb3243742')}</span>
|
<span className="text-xs text-slate-400 italic break-words">{t('autofix.kb3243742')}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -43,7 +42,7 @@ export default function MediaSection({ documents }: { documents: any[] }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-gray-500 italic py-6 text-center">{t('autofix.k60b1e339')}</div>
|
<div className="text-slate-500 italic py-6 text-center break-words">{t('autofix.k60b1e339')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,27 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../../i18n/useTranslation';
|
import { useTranslation } from '../../i18n/useTranslation';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { PROFILE_TOKENS } from './styleTokens'
|
||||||
|
|
||||||
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
export default function ProfileCompletion({ profileComplete }: { profileComplete: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6 mb-8">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6 mb-8`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.kd08b698a')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.kd08b698a')}</h2>
|
||||||
<span className="text-sm font-medium text-[#8D6B1D]">
|
<span className="text-sm font-medium text-[#8D6B1D]">
|
||||||
{profileComplete}%
|
{profileComplete}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-slate-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-gradient-to-r from-[#8D6B1D] to-[#C49225] h-2 rounded-full transition-all duration-300"
|
className="bg-gradient-to-r from-[#8D6B1D] to-[#C49225] h-2 rounded-full transition-all duration-300"
|
||||||
style={{ width: `${profileComplete}%` }}
|
style={{ width: `${profileComplete}%` }}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">{t('autofix.k772cc77b')}</p>
|
<p className="text-sm text-slate-600 mt-2 break-words">{t('autofix.k772cc77b')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/app/profile/components/styleTokens.ts
Normal file
20
src/app/profile/components/styleTokens.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export const PROFILE_TOKENS = {
|
||||||
|
masterPanel:
|
||||||
|
'rounded-[30px] bg-white/85 backdrop-blur-md border border-white/80 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)]',
|
||||||
|
card:
|
||||||
|
'rounded-[24px] bg-white/85 backdrop-blur-md border border-white/80 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.35)]',
|
||||||
|
innerCard: 'rounded-2xl border border-white/70 bg-white/80',
|
||||||
|
headerPill:
|
||||||
|
'inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500',
|
||||||
|
subtleButton: 'rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-50',
|
||||||
|
input: 'w-full p-2 border border-white/70 rounded-2xl bg-white/80 text-slate-900',
|
||||||
|
tableHead: 'px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal',
|
||||||
|
modalOverlay: 'fixed inset-0 z-50 flex items-center justify-center bg-black/35 backdrop-blur-sm transition-opacity duration-200',
|
||||||
|
modalPanel:
|
||||||
|
'rounded-[28px] p-4 sm:p-6 w-[calc(100%-2rem)] max-w-md max-h-[85dvh] overflow-y-auto transform transition-all duration-200 shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)]',
|
||||||
|
modalInput: 'w-full p-2 border border-slate-200 rounded-2xl bg-white/85 text-slate-900',
|
||||||
|
modalPrimaryButton:
|
||||||
|
'px-4 py-2 text-sm font-medium text-white bg-[#8D6B1D] rounded-2xl hover:bg-[#7A5E1A] transition',
|
||||||
|
modalSecondaryButton:
|
||||||
|
'px-4 py-2 text-sm font-medium text-slate-700 bg-slate-200 rounded-2xl hover:bg-slate-300 transition',
|
||||||
|
} as const
|
||||||
@ -1,7 +1,5 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
import { useTranslation } from '../i18n/useTranslation';
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -13,6 +11,7 @@ import BasicInformation from './components/basicInformation'
|
|||||||
import MediaSection from './components/mediaSection'
|
import MediaSection from './components/mediaSection'
|
||||||
import BankInformation from './components/bankInformation'
|
import BankInformation from './components/bankInformation'
|
||||||
import EditModal from './components/editModal'
|
import EditModal from './components/editModal'
|
||||||
|
import { PROFILE_TOKENS } from './components/styleTokens'
|
||||||
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
import { getProfileCompletion } from './hooks/getProfileCompletion'
|
||||||
import { useProfileData } from './hooks/getProfileData'
|
import { useProfileData } from './hooks/getProfileData'
|
||||||
import { useMedia } from './hooks/getMedia'
|
import { useMedia } from './hooks/getMedia'
|
||||||
@ -320,7 +319,7 @@ export default function ProfilePage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
<p className="text-[#4A4A4A]">Loading...</p>
|
<p className="text-[#4A4A4A] break-words">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -332,11 +331,14 @@ export default function ProfilePage() {
|
|||||||
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
<main className="py-6 sm:py-8 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
{/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */}
|
{/* MASTER GLASS PANEL (prevents non-translucent gaps between cards) */}
|
||||||
<div className="rounded-3xl bg-white/60 backdrop-blur-md border border-white/50 shadow-xl p-4 sm:p-6 lg:p-8">
|
<div className={`${PROFILE_TOKENS.masterPanel} p-4 sm:p-6 lg:p-8`}>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('autofix.k67cace8b')}</h1>
|
<div className={PROFILE_TOKENS.headerPill}>
|
||||||
<p className="text-gray-600 mt-2">{t('autofix.ka00fc5db')}</p>
|
Profile
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-950 break-words">{t('autofix.k67cace8b')}</h1>
|
||||||
|
<p className="text-slate-600 mt-2 break-words leading-6">{t('autofix.ka00fc5db')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending admin verification notice (above progress) */}
|
{/* Pending admin verification notice (above progress) */}
|
||||||
@ -365,48 +367,48 @@ export default function ProfilePage() {
|
|||||||
{/* Sidebar: Account Status + Quick Actions */}
|
{/* Sidebar: Account Status + Quick Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Account Status (make translucent) */}
|
{/* Account Status (make translucent) */}
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">{t('autofix.kb8cd2810')}</h3>
|
<h3 className="font-semibold text-slate-900 mb-4 break-words">{t('autofix.kb8cd2810')}</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600">{t('autofix.k7bed84a7')}</span>
|
<span className="text-sm text-slate-600 break-words pr-2">{t('autofix.k7bed84a7')}</span>
|
||||||
<span className="text-sm font-medium text-gray-900">{profileData.joinDate}</span>
|
<span className="text-sm font-medium text-slate-900 break-words text-right">{profileData.joinDate}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600">Status</span>
|
<span className="text-sm text-slate-600 break-words pr-2">Status</span>
|
||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gradient-to-r from-[#8D6B1D] to-[#C49225] text-white">
|
||||||
{profileData.memberStatus}
|
{profileData.memberStatus}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-600">Profile</span>
|
<span className="text-sm text-slate-600 break-words pr-2">Profile</span>
|
||||||
<span className="text-sm font-medium text-green-600">Verified</span>
|
<span className="text-sm font-medium text-green-600">Verified</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Quick Actions (make translucent) */}
|
{/* Quick Actions (make translucent) */}
|
||||||
<div className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<div className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<h3 className="font-semibold text-gray-900 mb-4">{t('autofix.k52af8b8d')}</h3>
|
<h3 className="font-semibold text-slate-900 mb-4 break-words">{t('autofix.k52af8b8d')}</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard')}
|
onClick={() => router.push('/dashboard')}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
|
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 rounded-2xl transition-colors break-words"
|
||||||
>{t('autofix.kd00443f2')}</button>
|
>{t('autofix.kd00443f2')}</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadAccountData}
|
onClick={handleDownloadAccountData}
|
||||||
disabled={downloadLoading}
|
disabled={downloadLoading}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 rounded-lg transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
className="w-full text-left px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 rounded-2xl transition-colors disabled:opacity-60 disabled:cursor-not-allowed break-words"
|
||||||
>
|
>
|
||||||
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
|
{downloadLoading ? 'Preparing download...' : 'Download Account Data'}
|
||||||
</button>
|
</button>
|
||||||
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">{t('autofix.k41f7c81d')}</button>
|
<button className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-2xl transition-colors break-words">{t('autofix.k41f7c81d')}</button>
|
||||||
</div>
|
</div>
|
||||||
{downloadError && (
|
{downloadError && (
|
||||||
<p className="mt-2 text-xs text-red-600">{downloadError}</p>
|
<p className="mt-2 text-xs text-red-600 break-words">{downloadError}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -414,25 +416,25 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
{/* Bank Info, Media */}
|
{/* Bank Info, Media */}
|
||||||
<div className="space-y-6 sm:space-y-8 mb-8">
|
<div className="space-y-6 sm:space-y-8 mb-8">
|
||||||
<section className="rounded-lg bg-white/70 backdrop-blur-md border border-white/60 shadow-lg p-4 sm:p-6">
|
<section className={`${PROFILE_TOKENS.card} p-4 sm:p-6`}>
|
||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('autofix.k744fda01')}</h2>
|
<h2 className="text-lg font-semibold text-slate-900 break-words">{t('autofix.k744fda01')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">{t('autofix.kcada239b')}</p>
|
<p className="text-sm text-slate-600 mt-1 break-words">{t('autofix.kcada239b')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/profile/subscriptions')}
|
onClick={() => router.push('/profile/subscriptions')}
|
||||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
className={PROFILE_TOKENS.subtleButton}
|
||||||
>{t('autofix.k4b6c7681')}</button>
|
>{t('autofix.k4b6c7681')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 rounded-md border border-white/60 bg-white/70 p-3">
|
<div className={`mt-4 ${PROFILE_TOKENS.innerCard} p-3`}>
|
||||||
{subscriptionsLoading ? (
|
{subscriptionsLoading ? (
|
||||||
<p className="text-sm text-gray-600">{t('autofix.kcdfef775')}</p>
|
<p className="text-sm text-slate-600 break-words">{t('autofix.kcdfef775')}</p>
|
||||||
) : subscriptionsError ? (
|
) : subscriptionsError ? (
|
||||||
<p className="text-sm text-red-700">{subscriptionsError}</p>
|
<p className="text-sm text-red-700 break-words">{subscriptionsError}</p>
|
||||||
) : subscriptions.length === 0 ? (
|
) : subscriptions.length === 0 ? (
|
||||||
<p className="text-sm text-gray-600">{t('autofix.kc8652e34')}</p>
|
<p className="text-sm text-slate-600 break-words">{t('autofix.kc8652e34')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{subscriptions.map((subscription) => {
|
{subscriptions.map((subscription) => {
|
||||||
@ -445,8 +447,8 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<li key={subscription.id} className="flex items-center justify-between gap-3 text-sm border-b border-gray-200/60 pb-2 last:border-0 last:pb-0">
|
<li key={subscription.id} className="flex items-center justify-between gap-3 text-sm border-b border-gray-200/60 pb-2 last:border-0 last:pb-0">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-gray-900 truncate">{subscription.name || `Subscription #${subscription.id}`}</p>
|
<p className="font-medium text-slate-900 break-words">{subscription.name || `Subscription #${subscription.id}`}</p>
|
||||||
<p className="text-xs text-gray-600">Started: {startedLabel} • Packs: {packs}</p>
|
<p className="text-xs text-slate-600 break-words">Started: {startedLabel} • Packs: {packs}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700">
|
||||||
{String(subscription.status || 'ongoing')}
|
{String(subscription.status || 'ongoing')}
|
||||||
|
|||||||
@ -118,54 +118,54 @@ export default function GenerateReferralLinkWidget({ onCreated }: Props) {
|
|||||||
const disableMaxUses = lockedBy === 'exp'
|
const disableMaxUses = lockedBy === 'exp'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="rounded-[28px] border border-white/80 bg-white/85 p-6 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur">
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('referralManagement.generateTitle')}</h2>
|
<h2 className="text-xl font-bold text-slate-950 mb-4 break-words">{t('referralManagement.generateTitle')}</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('referralManagement.maxUsesLabel')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('referralManagement.maxUsesLabel')}</label>
|
||||||
<select
|
<select
|
||||||
value={maxUses}
|
value={maxUses}
|
||||||
onChange={(e) => onChangeMaxUses(e.target.value)}
|
onChange={(e) => onChangeMaxUses(e.target.value)}
|
||||||
disabled={disableMaxUses}
|
disabled={disableMaxUses}
|
||||||
className="w-full rounded-md border border-gray-300 text-gray-900 bg-white focus:border-[#8D6B1D] focus:ring-[#8D6B1D] disabled:bg-gray-100 disabled:text-gray-500"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-slate-300 focus:outline-none focus:ring-2 focus:ring-slate-900/20 disabled:bg-slate-100 disabled:text-slate-500"
|
||||||
>
|
>
|
||||||
{maxUsesOptions.map(opt => (
|
{maxUsesOptions.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{disableMaxUses && <p className="mt-1 text-xs text-gray-500">{t('referralManagement.lockedByNeverExpires')}</p>}
|
{disableMaxUses && <p className="mt-1 text-xs text-slate-500 break-words">{t('referralManagement.lockedByNeverExpires')}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('referralManagement.expiresIn')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1 break-words">{t('referralManagement.expiresIn')}</label>
|
||||||
<select
|
<select
|
||||||
value={expiresInDays}
|
value={expiresInDays}
|
||||||
onChange={(e) => onChangeExpires(e.target.value)}
|
onChange={(e) => onChangeExpires(e.target.value)}
|
||||||
disabled={disableExpires}
|
disabled={disableExpires}
|
||||||
className="w-full rounded-md border border-gray-300 text-gray-900 bg-white focus:border-[#8D6B1D] focus:ring-[#8D6B1D] disabled:bg-gray-100 disabled:text-gray-500"
|
className="w-full rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-slate-300 focus:outline-none focus:ring-2 focus:ring-slate-900/20 disabled:bg-slate-100 disabled:text-slate-500"
|
||||||
>
|
>
|
||||||
{expiryOptions.map(opt => (
|
{expiryOptions.map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{disableExpires && <p className="mt-1 text-xs text-gray-500">{t('referralManagement.lockedByUnlimited')}</p>}
|
{disableExpires && <p className="mt-1 text-xs text-slate-500 break-words">{t('referralManagement.lockedByUnlimited')}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
<div className="mt-6 flex flex-wrap items-start gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onGenerate}
|
onClick={onGenerate}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating}
|
||||||
className="inline-flex items-center gap-2 rounded-md bg-[#8D6B1D] px-4 py-2 text-white hover:bg-[#7A5E1A] disabled:opacity-60"
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#8D6B1D] px-4 py-2 text-sm font-semibold text-white shadow-[0_18px_40px_-24px_rgba(141,107,29,0.85)] transition hover:bg-[#7A5E1A] disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{isGenerating ? t('referralManagement.generating') : t('referralManagement.generateLink')}
|
{isGenerating ? t('referralManagement.generating') : t('referralManagement.generateLink')}
|
||||||
</button>
|
</button>
|
||||||
{generatedLink && (
|
{generatedLink && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-1 min-w-[16rem] flex-wrap items-center gap-2">
|
||||||
<code className="rounded bg-gray-100 px-3 py-2 text-sm text-gray-700 break-all">{generatedLink}</code>
|
<code className="max-w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700 break-all">{generatedLink}</code>
|
||||||
<button
|
<button
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
className="inline-flex items-center gap-1 rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 hover:bg-gray-50"
|
className="inline-flex items-center gap-1 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 transition hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
{isCopying ? t('referralManagement.copied') : t('referralManagement.copy')}
|
{isCopying ? t('referralManagement.copied') : t('referralManagement.copy')}
|
||||||
|
|||||||
@ -74,20 +74,20 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
}, [percent])
|
}, [percent])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`w-full rounded-xl border border-gray-200 bg-white shadow-sm ${className}`}>
|
<div className={`w-full rounded-[24px] border border-white/80 bg-white/85 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.35)] backdrop-blur ${className}`}>
|
||||||
<div className="p-4 sm:p-5">
|
<div className="p-4 sm:p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex flex-wrap items-start justify-between gap-2 mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-sm font-semibold text-gray-900">{t('referralManagement.levelLabel')}</span>
|
<span className="text-sm font-semibold text-slate-900 break-words">{t('referralManagement.levelLabel')}</span>
|
||||||
<span className="text-lg font-bold text-indigo-700">#{displayLevel}</span>
|
<span className="text-lg font-bold text-indigo-700">#{displayLevel}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* NEW: level name badge */}
|
{/* NEW: level name badge */}
|
||||||
<span className="inline-flex items-center rounded-full bg-indigo-50 text-indigo-700 px-2 py-[2px] text-[11px] font-semibold">
|
<span className="inline-flex items-center rounded-full bg-indigo-50 text-indigo-700 px-2 py-[2px] text-[11px] font-semibold break-words">
|
||||||
{levelName}
|
{levelName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-slate-600 break-words">
|
||||||
{targetProgress} {t('referralManagement.of')} {target} {t('referralManagement.referrals')}
|
{targetProgress} {t('referralManagement.of')} {target} {t('referralManagement.referrals')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -101,7 +101,7 @@ export default function LevelTrackerWidget({ points = 3, className = '' }: Props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center justify-between">
|
<div className="mt-2 flex items-center justify-between">
|
||||||
<span className="text-[11px] font-medium text-gray-600">
|
<span className="text-[11px] font-medium text-slate-600 break-words pr-2">
|
||||||
{displayLevel >= LEVELS.length
|
{displayLevel >= LEVELS.length
|
||||||
? t('referralManagement.maxLevelReached')
|
? t('referralManagement.maxLevelReached')
|
||||||
: `${t('referralManagement.nextMilestone')}: ${target} ${t('referralManagement.referrals')}`}
|
: `${t('referralManagement.nextMilestone')}: ${target} ${t('referralManagement.referrals')}`}
|
||||||
|
|||||||
@ -78,28 +78,28 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div className="mt-8 rounded-[28px] border border-white/80 bg-white/85 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur overflow-hidden">
|
||||||
<div className="p-6 border-b border-gray-100">
|
<div className="p-6 border-b border-slate-200/60 bg-white/40">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{t('referralManagement.allLinks')}</h2>
|
<h2 className="text-xl font-bold text-slate-950 break-words">{t('referralManagement.allLinks')}</h2>
|
||||||
<p className="text-sm text-gray-600 mt-1">{t('referralManagement.allLinksSubtitle')}</p>
|
<p className="text-sm text-slate-600 mt-1 break-words">{t('referralManagement.allLinksSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-slate-50/80">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colLink')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colLink')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colCreated')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colCreated')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colExpires')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colExpires')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUsage')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colUsage')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colStatus')}</th>
|
||||||
<th className="px-6 py-3" />
|
<th className="px-6 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-slate-100 bg-white/75">
|
||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-6 text-sm text-gray-500">
|
<td colSpan={6} className="px-6 py-6 text-sm text-slate-500 break-words">
|
||||||
{t('referralManagement.noLinks')}
|
{t('referralManagement.noLinks')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -154,7 +154,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
return (
|
return (
|
||||||
<tr key={l.id || l.code}>
|
<tr key={l.id || l.code}>
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm">
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex flex-wrap items-center gap-2 min-w-0">
|
||||||
{/* Desktop/Tablet: show preview + tooltip */}
|
{/* Desktop/Tablet: show preview + tooltip */}
|
||||||
<a
|
<a
|
||||||
href={l.url}
|
href={l.url}
|
||||||
@ -163,14 +163,14 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
onMouseEnter={(e) => showTooltip(e, l.url || '')}
|
onMouseEnter={(e) => showTooltip(e, l.url || '')}
|
||||||
onMouseMove={moveTooltip}
|
onMouseMove={moveTooltip}
|
||||||
onMouseLeave={hideTooltip}
|
onMouseLeave={hideTooltip}
|
||||||
className="hidden md:inline text-[#8D6B1D] hover:text-[#7A5E1A] font-mono truncate max-w-[240px] lg:max-w-[360px]"
|
className="hidden md:inline text-[#8D6B1D] hover:text-[#7A5E1A] font-mono break-all max-w-[240px] lg:max-w-[360px]"
|
||||||
>
|
>
|
||||||
{shortLink(l.url)}
|
{shortLink(l.url)}
|
||||||
</a>
|
</a>
|
||||||
{/* Desktop/Tablet copy button */}
|
{/* Desktop/Tablet copy button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
||||||
className="hidden md:inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-1 text-xs text-gray-700
|
className="hidden md:inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-2 py-1 text-xs text-slate-700
|
||||||
transition-all duration-150 hover:bg-gray-50 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0"
|
transition-all duration-150 hover:bg-gray-50 hover:shadow-sm hover:-translate-y-0.5 active:translate-y-0"
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
@ -179,7 +179,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
{/* Mobile: only copy button */}
|
{/* Mobile: only copy button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
onClick={() => copyToClipboard(l.url || String(l.code || ''))}
|
||||||
className="inline-flex md:hidden items-center gap-2 rounded border border-gray-300 px-3 py-2 text-xs text-gray-700 hover:bg-gray-50"
|
className="inline-flex md:hidden items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs text-slate-700 hover:bg-slate-50"
|
||||||
aria-label={t('autofix.k77d5ecd9')}
|
aria-label={t('autofix.k77d5ecd9')}
|
||||||
>
|
>
|
||||||
<ClipboardDocumentIcon className="h-4 w-4" />
|
<ClipboardDocumentIcon className="h-4 w-4" />
|
||||||
@ -189,28 +189,28 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Created - badge */}
|
{/* Created - badge */}
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm break-words">
|
||||||
<span className="inline-flex items-center rounded px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800">
|
<span className="inline-flex items-center rounded px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
{created}
|
{created}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Expires - badge */}
|
{/* Expires - badge */}
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm break-words">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${expiresBadge}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${expiresBadge}`}>
|
||||||
{expires}
|
{expires}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Usage - badge */}
|
{/* Usage - badge */}
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm break-words">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${usageBadge}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${usageBadge}`}>
|
||||||
{usage}
|
{usage}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Status - existing badge */}
|
{/* Status - existing badge */}
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm break-words">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadge}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${statusBadge}`}>
|
||||||
{String(l.status || 'active')}
|
{String(l.status || 'active')}
|
||||||
</span>
|
</span>
|
||||||
@ -221,7 +221,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
disabled={l.status !== 'active'}
|
disabled={l.status !== 'active'}
|
||||||
onClick={() => onDeactivate(l)}
|
onClick={() => onDeactivate(l)}
|
||||||
className="
|
className="
|
||||||
inline-flex items-center rounded-md border border-red-300 px-3 py-1.5 text-sm text-red-700
|
inline-flex items-center rounded-xl border border-red-200 bg-red-50/70 px-3 py-1.5 text-sm text-red-700
|
||||||
transition-all duration-150
|
transition-all duration-150
|
||||||
md:hover:-translate-y-0.5 md:hover:shadow-sm md:hover:bg-red-50
|
md:hover:-translate-y-0.5 md:hover:shadow-sm md:hover:bg-red-50
|
||||||
active:translate-y-0
|
active:translate-y-0
|
||||||
@ -246,7 +246,7 @@ export default function ReferralLinksListWidget({ links, onDeactivate }: Props)
|
|||||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||||
>
|
>
|
||||||
{tooltip.visible && (
|
{tooltip.visible && (
|
||||||
<div className="max-w-[80vw] break-words rounded-md bg-gray-900 text-white text-xs px-3 py-2 shadow-lg ring-1 ring-black/10">
|
<div className="max-w-[80vw] break-words rounded-xl bg-slate-900 text-white text-xs px-3 py-2 shadow-lg ring-1 ring-black/10">
|
||||||
{tooltip.text}
|
{tooltip.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -28,14 +28,14 @@ const renderStatCard = (
|
|||||||
c: { label: string; value: number; icon: any; color: string },
|
c: { label: string; value: number; icon: any; color: string },
|
||||||
key: React.Key
|
key: React.Key
|
||||||
) => (
|
) => (
|
||||||
<div key={key} className="relative overflow-hidden rounded-lg bg-white px-4 pb-6 pt-5 shadow-sm border border-gray-200 sm:px-6 sm:pt-6">
|
<div key={key} className="relative overflow-hidden rounded-[24px] border border-white/80 bg-white/85 px-4 pb-6 pt-5 shadow-[0_20px_50px_-34px_rgba(15,23,42,0.35)] backdrop-blur sm:px-6 sm:pt-6">
|
||||||
<dt>
|
<dt>
|
||||||
<div className={`absolute rounded-md ${c.color} p-3`}>
|
<div className={`absolute rounded-xl ${c.color} p-3 shadow-sm`}>
|
||||||
<c.icon className="h-6 w-6 text-white" aria-hidden="true" />
|
<c.icon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<p className="ml-16 truncate text-sm font-medium text-gray-500">{c.label}</p>
|
<p className="ml-16 text-sm font-medium text-slate-600 break-words leading-5">{c.label}</p>
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="ml-16 mt-2 text-2xl font-semibold text-gray-900">{c.value}</dd>
|
<dd className="ml-16 mt-2 text-2xl font-semibold text-slate-900">{c.value}</dd>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -61,10 +61,10 @@ export default function ReferralStatisticWidget({ stats, totalReferredFromBacken
|
|||||||
<>
|
<>
|
||||||
<LevelTrackerWidget points={totalReferred} className="mb-6" />
|
<LevelTrackerWidget points={totalReferred} className="mb-6" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 mb-5">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 mb-4">
|
||||||
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
|
{topStats.map((c, i) => renderStatCard(c, `top-${i}`))}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 mb-10">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 mb-8">
|
||||||
{bottomStats.map((c, i) => renderStatCard(c, `bottom-${i}`))}
|
{bottomStats.map((c, i) => renderStatCard(c, `bottom-${i}`))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -128,10 +128,10 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mt-8 mb-8 bg-white rounded-lg shadow-sm border border-gray-200">
|
<div className="mt-8 mb-8 rounded-[28px] border border-white/80 bg-white/85 shadow-[0_22px_60px_-34px_rgba(15,23,42,0.28)] backdrop-blur overflow-hidden">
|
||||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between gap-4">
|
<div className="p-6 border-b border-slate-200/60 bg-white/40 flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">{t('referralManagement.registeredUsersTitle')}</h2>
|
<h2 className="text-xl font-bold text-slate-950 break-words">{t('referralManagement.registeredUsersTitle')}</h2>
|
||||||
<div className="mt-2 inline-flex items-center gap-2">
|
<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">
|
<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" />
|
<UsersIcon className="h-4 w-4" />
|
||||||
@ -141,33 +141,33 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
{totalRegistered}
|
{totalRegistered}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-slate-600 mt-2 break-words">
|
||||||
{t('referralManagement.registeredUsersSubtitle')}
|
{t('referralManagement.registeredUsersSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-slate-500 mt-2 break-words">
|
||||||
{t('referralManagement.showingLatest5')}
|
{t('referralManagement.showingLatest5')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={resetAndOpen}
|
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"
|
className="inline-flex items-center rounded-2xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500"
|
||||||
>
|
>
|
||||||
{t('referralManagement.viewAll')}
|
{t('referralManagement.viewAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-slate-50/80">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUser')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colUser')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colEmail')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colEmail')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colType')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colType')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colRegistered')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colRegistered')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colStatus')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-slate-100 bg-white/75">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
<tr><td className="px-6 py-4" colSpan={5}><div className="h-4 w-40 bg-gray-200 animate-pulse rounded" /></td></tr>
|
||||||
@ -176,7 +176,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
</>
|
</>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
<td className="px-6 py-6 text-sm text-slate-500 break-words" colSpan={5}>
|
||||||
{t('referralManagement.noRegisteredUsers')}
|
{t('referralManagement.noRegisteredUsers')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -185,14 +185,14 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
const date = new Date(u.registeredAt).toLocaleString()
|
const date = new Date(u.registeredAt).toLocaleString()
|
||||||
return (
|
return (
|
||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
<td className="px-6 py-4 text-sm text-gray-900">{u.name}</td>
|
<td className="px-6 py-4 text-sm text-slate-900 break-words">{u.name}</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-700">{u.email}</td>
|
<td className="px-6 py-4 text-sm text-slate-700 break-words">{u.email}</td>
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-6 py-4 text-sm">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||||
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-700">{date}</td>
|
<td className="px-6 py-4 text-sm text-slate-700 break-words">{date}</td>
|
||||||
<td className="px-6 py-4 text-sm">
|
<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)}`}>
|
<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)}
|
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||||
@ -210,41 +210,41 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
{/* Modal with full list */}
|
{/* Modal with full list */}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true">
|
<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 backdrop-blur-sm" onClick={() => setOpen(false)} aria-hidden />
|
||||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
<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="w-full max-w-6xl rounded-[28px] border border-white/80 bg-white/90 backdrop-blur shadow-[0_32px_80px_-40px_rgba(15,23,42,0.42)] overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between gap-3">
|
<div className="px-6 py-4 border-b border-slate-200/70 bg-white/45 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{t('referralManagement.allRegisteredUsersTitle')}</h3>
|
<h3 className="text-lg font-bold text-slate-900 break-words">{t('referralManagement.allRegisteredUsersTitle')}</h3>
|
||||||
<p className="text-xs text-gray-600">{t('referralManagement.allRegisteredUsersSubtitle')}</p>
|
<p className="text-xs text-slate-600 break-words">{t('referralManagement.allRegisteredUsersSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => exportCsv(filtered)}
|
onClick={() => exportCsv(filtered)}
|
||||||
className="inline-flex items-center rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
className="inline-flex items-center rounded-2xl border border-slate-200 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
{t('referralManagement.exportCsv')}
|
{t('referralManagement.exportCsv')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="inline-flex items-center rounded-md bg-gray-900 px-3 py-1.5 text-sm text-white hover:bg-gray-800"
|
className="inline-flex items-center rounded-2xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
{t('common.close')}
|
{t('common.close')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-b border-gray-100 grid grid-cols-1 md:grid-cols-4 gap-3">
|
<div className="px-6 py-4 border-b border-slate-100 grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
onChange={e => { setQuery(e.target.value); setPage(1) }}
|
||||||
placeholder={t('referralManagement.searchPlaceholder')}
|
placeholder={t('referralManagement.searchPlaceholder')}
|
||||||
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"
|
className="md:col-span-2 w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300 placeholder:text-slate-500"
|
||||||
/>
|
/>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
onChange={e => { setTypeFilter(e.target.value as any); setPage(1) }}
|
||||||
className="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"
|
className="w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300"
|
||||||
>
|
>
|
||||||
<option value="all">{t('referralManagement.filterAllTypes')}</option>
|
<option value="all">{t('referralManagement.filterAllTypes')}</option>
|
||||||
<option value="personal">{t('referralManagement.typePersonal')}</option>
|
<option value="personal">{t('referralManagement.typePersonal')}</option>
|
||||||
@ -253,7 +253,7 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
onChange={e => { setStatusFilter(e.target.value as any); setPage(1) }}
|
||||||
className="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"
|
className="w-full rounded-2xl border border-slate-200 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:outline-none focus:ring-2 focus:ring-slate-900/20 focus:border-slate-300"
|
||||||
>
|
>
|
||||||
<option value="all">{t('referralManagement.filterAllStatus')}</option>
|
<option value="all">{t('referralManagement.filterAllStatus')}</option>
|
||||||
<option value="active">{t('referralManagement.filterActive')}</option>
|
<option value="active">{t('referralManagement.filterActive')}</option>
|
||||||
@ -264,20 +264,20 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-slate-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-slate-50/80">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colUser')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colUser')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colEmail')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colEmail')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colType')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colType')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colRegistered')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colRegistered')}</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('referralManagement.colStatus')}</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide whitespace-normal">{t('referralManagement.colStatus')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-slate-100 bg-white/75">
|
||||||
{pageRows.length === 0 ? (
|
{pageRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="px-6 py-6 text-sm text-gray-500" colSpan={5}>
|
<td className="px-6 py-6 text-sm text-slate-500 break-words" colSpan={5}>
|
||||||
{t('referralManagement.noUsersMatchFilters')}
|
{t('referralManagement.noUsersMatchFilters')}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -286,14 +286,14 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
const date = new Date(u.registeredAt).toLocaleString()
|
const date = new Date(u.registeredAt).toLocaleString()
|
||||||
return (
|
return (
|
||||||
<tr key={u.id}>
|
<tr key={u.id}>
|
||||||
<td className="px-6 py-3 text-sm text-gray-900">{u.name}</td>
|
<td className="px-6 py-3 text-sm text-slate-900 break-words">{u.name}</td>
|
||||||
<td className="px-6 py-3 text-sm text-gray-700">{u.email}</td>
|
<td className="px-6 py-3 text-sm text-slate-700 break-words">{u.email}</td>
|
||||||
<td className="px-6 py-3 text-sm">
|
<td className="px-6 py-3 text-sm">
|
||||||
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
<span className={`inline-flex items-center rounded px-2 py-1 text-xs font-medium ${typeBadgeClass(u.userType)}`}>
|
||||||
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
{u.userType === 'company' ? t('referralManagement.typeCompany') : t('referralManagement.typePersonal')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-sm text-gray-700">{date}</td>
|
<td className="px-6 py-3 text-sm text-slate-700 break-words">{date}</td>
|
||||||
<td className="px-6 py-3 text-sm">
|
<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)}`}>
|
<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)}
|
{u.status.charAt(0).toUpperCase() + u.status.slice(1)}
|
||||||
@ -307,25 +307,25 @@ export default function RegisteredUserList({ users, loading }: Props) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 flex items-center justify-between gap-3">
|
<div className="px-6 py-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<span className="text-xs text-gray-600">
|
<span className="text-xs text-slate-600 break-words">
|
||||||
{t('referralManagement.showing')} {pageRows.length} {t('referralManagement.of')} {filtered.length} {t('referralManagement.colUser').toLowerCase()}s
|
{t('referralManagement.showing')} {pageRows.length} {t('referralManagement.of')} {filtered.length} {t('referralManagement.colUser').toLowerCase()}s
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={page <= 1}
|
disabled={page <= 1}
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
className="rounded-xl border border-slate-200 px-3 py-1.5 text-sm text-slate-700 disabled:opacity-50 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
{t('referralManagement.pagePrev')}
|
{t('referralManagement.pagePrev')}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm text-gray-700">
|
<span className="text-sm text-slate-700 break-words">
|
||||||
{t('referralManagement.pageOf').replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
{t('referralManagement.pageOf').replace('{page}', String(page)).replace('{total}', String(totalPages))}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
disabled={page >= totalPages}
|
disabled={page >= totalPages}
|
||||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||||
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 disabled:opacity-50 hover:bg-gray-50"
|
className="rounded-xl border border-slate-200 px-3 py-1.5 text-sm text-slate-700 disabled:opacity-50 hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
{t('referralManagement.pageNext')}
|
{t('referralManagement.pageNext')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -278,7 +278,7 @@ function ReferralManagementPageInner() {
|
|||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
|
||||||
<p className="text-slate-700">{t('common.loading')}</p>
|
<p className="text-slate-700 break-words">{t('common.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -287,12 +287,15 @@ function ReferralManagementPageInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.10),transparent_22%),radial-gradient(circle_at_top_right,rgba(56,189,248,0.10),transparent_24%),linear-gradient(180deg,#f8fafc_0%,#f8fafc_50%,#eef2ff_100%)]">
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 rounded-[30px] border border-white/80 bg-white/85 px-5 py-6 shadow-[0_24px_70px_-40px_rgba(15,23,42,0.38)] backdrop-blur md:px-8 md:py-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{t('referralManagement.title')}</h1>
|
<div className="inline-flex items-center rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-slate-500">
|
||||||
<p className="text-gray-600 mt-2">
|
Referral
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-950 md:text-4xl break-words">{t('referralManagement.title')}</h1>
|
||||||
|
<p className="text-slate-600 mt-2 max-w-3xl leading-6 break-words">
|
||||||
{t('referralManagement.description')}
|
{t('referralManagement.description')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -13,7 +13,9 @@ export type DashboardPlatformColorClass =
|
|||||||
export type DashboardPlatform = {
|
export type DashboardPlatform = {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
titleKey?: string
|
||||||
description: string
|
description: string
|
||||||
|
descriptionKey?: string
|
||||||
href: string
|
href: string
|
||||||
icon: DashboardPlatformIconName
|
icon: DashboardPlatformIconName
|
||||||
color: DashboardPlatformColorClass
|
color: DashboardPlatformColorClass
|
||||||
@ -92,7 +94,9 @@ function normalizePlatform(input: unknown): DashboardPlatform | null {
|
|||||||
|
|
||||||
const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : createFallbackId()
|
const id = typeof input.id === 'string' && input.id.trim() ? input.id.trim() : createFallbackId()
|
||||||
const title = typeof input.title === 'string' ? input.title : ''
|
const title = typeof input.title === 'string' ? input.title : ''
|
||||||
|
const titleKey = typeof input.titleKey === 'string' ? input.titleKey : undefined
|
||||||
const description = typeof input.description === 'string' ? input.description : ''
|
const description = typeof input.description === 'string' ? input.description : ''
|
||||||
|
const descriptionKey = typeof input.descriptionKey === 'string' ? input.descriptionKey : undefined
|
||||||
const href = typeof input.href === 'string' ? input.href : ''
|
const href = typeof input.href === 'string' ? input.href : ''
|
||||||
const icon = input.icon as DashboardPlatformIconName
|
const icon = input.icon as DashboardPlatformIconName
|
||||||
const color = input.color as DashboardPlatformColorClass
|
const color = input.color as DashboardPlatformColorClass
|
||||||
@ -108,7 +112,9 @@ function normalizePlatform(input: unknown): DashboardPlatform | null {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
|
titleKey,
|
||||||
description,
|
description,
|
||||||
|
descriptionKey,
|
||||||
href,
|
href,
|
||||||
icon,
|
icon,
|
||||||
color,
|
color,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user