756 lines
33 KiB
TypeScript
756 lines
33 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import {
|
|
Settings,
|
|
Save,
|
|
Loader2,
|
|
User,
|
|
Shield,
|
|
FileText,
|
|
Building,
|
|
RefreshCw,
|
|
Upload,
|
|
Image as ImageIcon,
|
|
KeyRound,
|
|
LayoutDashboard,
|
|
Search,
|
|
Copy,
|
|
Check,
|
|
Lock,
|
|
} from 'lucide-react';
|
|
import { parseJwt, getAccessToken } from '@/services/api/client';
|
|
import { userService, UserResponse } from '@/services/api/userService';
|
|
import { toast } from 'sonner';
|
|
import { format } from 'date-fns';
|
|
|
|
type SettingsBlade = 'overview' | 'profile' | 'security' | 'branding';
|
|
|
|
function PropertyRow({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div className="grid grid-cols-1 sm:grid-cols-[118px_minmax(0,1fr)] gap-x-3 gap-y-1 py-3 border-b border-border items-start sm:items-center min-w-0">
|
|
<dt className="text-[13px] text-muted-foreground leading-5 truncate">{label}</dt>
|
|
<dd className="text-[13px] font-semibold text-foreground leading-5 min-w-0 overflow-hidden">
|
|
{typeof children === 'string' ? (
|
|
<span className="block truncate" title={children}>
|
|
{children}
|
|
</span>
|
|
) : (
|
|
children
|
|
)}
|
|
</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function SettingsPage() {
|
|
const [userRole, setUserRole] = useState('');
|
|
const [blade, setBlade] = useState<SettingsBlade>('overview');
|
|
const [navSearch, setNavSearch] = useState('');
|
|
const [copiedId, setCopiedId] = useState(false);
|
|
|
|
const [profile, setProfile] = useState<UserResponse | null>(null);
|
|
const [loadingProfile, setLoadingProfile] = useState(true);
|
|
const [firstName, setFirstName] = useState('');
|
|
const [lastName, setLastName] = useState('');
|
|
const [userName, setUserName] = useState('');
|
|
const [phone, setPhone] = useState('');
|
|
const [email, setEmail] = useState('');
|
|
const [savingProfile, setSavingProfile] = useState(false);
|
|
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
const [newPassword, setNewPassword] = useState('');
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
const [savingPassword, setSavingPassword] = useState(false);
|
|
|
|
const [invoiceLogoUrl, setInvoiceLogoUrl] = useState('https://ifixkart.com/logo.png');
|
|
const [invoiceCompanyName, setInvoiceCompanyName] = useState('iFixKart Solutions Platform');
|
|
const [invoiceGstin, setInvoiceGstin] = useState('33AAAAA0000A1Z5');
|
|
const [invoiceStoreAddress, setInvoiceStoreAddress] = useState(
|
|
'Offline Main Store Counter, Chennai | +91 9876543210'
|
|
);
|
|
const [invoiceGstRate, setInvoiceGstRate] = useState('18.0');
|
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
|
|
|
const isSuperAdmin =
|
|
userRole === 'super admin' || userRole === 'super_admin' || userRole === 'superadmin';
|
|
|
|
const fetchBrandingSettings = async () => {
|
|
if (typeof window === 'undefined') return;
|
|
const savedBranding = localStorage.getItem('ifixkart_invoice_branding');
|
|
if (savedBranding) {
|
|
try {
|
|
const parsed = JSON.parse(savedBranding);
|
|
if (parsed.logoUrl) setInvoiceLogoUrl(parsed.logoUrl);
|
|
if (parsed.companyName) setInvoiceCompanyName(parsed.companyName);
|
|
if (parsed.gstin) setInvoiceGstin(parsed.gstin);
|
|
if (parsed.storeAddress) setInvoiceStoreAddress(parsed.storeAddress);
|
|
if (parsed.gstRate !== undefined) setInvoiceGstRate(String(parsed.gstRate));
|
|
} catch {
|
|
/* ignore corrupt local branding */
|
|
}
|
|
}
|
|
|
|
try {
|
|
const token = getAccessToken();
|
|
const backendUrl = '';
|
|
const res = await fetch(`${backendUrl}/api/v1/settings/key/invoice_branding`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const val = data.setting_value;
|
|
if (val && typeof val === 'object') {
|
|
if (val.logoUrl) setInvoiceLogoUrl(val.logoUrl);
|
|
if (val.companyName) setInvoiceCompanyName(val.companyName);
|
|
if (val.gstin) setInvoiceGstin(val.gstin);
|
|
if (val.storeAddress) setInvoiceStoreAddress(val.storeAddress);
|
|
if (val.gstRate !== undefined) setInvoiceGstRate(String(val.gstRate));
|
|
}
|
|
}
|
|
} catch {
|
|
/* branding endpoint optional */
|
|
}
|
|
};
|
|
|
|
const fetchUserProfile = async () => {
|
|
setLoadingProfile(true);
|
|
try {
|
|
const token = getAccessToken();
|
|
if (!token) return;
|
|
|
|
const payload = parseJwt(token);
|
|
const userId = payload?.sub as string;
|
|
if (!userId) return;
|
|
|
|
const user = await userService.getProfile(userId);
|
|
setProfile(user);
|
|
setFirstName(user.first_name || '');
|
|
setLastName(user.last_name || '');
|
|
setPhone(user.phone || '');
|
|
setEmail(user.email || '');
|
|
setUserName(user.display_name || user.email.split('@')[0]);
|
|
} catch {
|
|
toast.error('Failed to load user profile');
|
|
} finally {
|
|
setLoadingProfile(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const token = getAccessToken();
|
|
if (token) {
|
|
const payload = parseJwt(token);
|
|
if (payload) {
|
|
setUserRole(((payload.role as string) || '').toLowerCase());
|
|
}
|
|
}
|
|
fetchBrandingSettings();
|
|
fetchUserProfile();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (blade === 'branding' && !isSuperAdmin) setBlade('overview');
|
|
}, [blade, isSuperAdmin]);
|
|
|
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!profile) return;
|
|
setSavingProfile(true);
|
|
try {
|
|
await userService.update(profile.user_id, {
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
phone: phone,
|
|
display_name: userName,
|
|
});
|
|
toast.success('Profile saved');
|
|
fetchUserProfile();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
|
toast.error(message);
|
|
} finally {
|
|
setSavingProfile(false);
|
|
}
|
|
};
|
|
|
|
const handleDiscardProfile = () => {
|
|
if (!profile) return;
|
|
setFirstName(profile.first_name || '');
|
|
setLastName(profile.last_name || '');
|
|
setPhone(profile.phone || '');
|
|
setEmail(profile.email || '');
|
|
setUserName(profile.display_name || profile.email.split('@')[0]);
|
|
};
|
|
|
|
const handleUpdatePassword = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newPassword || !confirmPassword) {
|
|
toast.error('Enter a new password');
|
|
return;
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
toast.error('New password and confirmation do not match');
|
|
return;
|
|
}
|
|
setSavingPassword(true);
|
|
try {
|
|
if (!profile) return;
|
|
await userService.update(profile.user_id, {
|
|
password: newPassword,
|
|
});
|
|
toast.success('Password updated');
|
|
setCurrentPassword('');
|
|
setNewPassword('');
|
|
setConfirmPassword('');
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Password update failed';
|
|
toast.error(message);
|
|
} finally {
|
|
setSavingPassword(false);
|
|
}
|
|
};
|
|
|
|
const handleLogoFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
setUploadingLogo(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('entity_type', 'invoice_branding');
|
|
formData.append('entity_id', 'logo');
|
|
|
|
const token = getAccessToken();
|
|
const backendUrl = '';
|
|
const res = await fetch(`${backendUrl}/api/v1/files/upload`, {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
body: formData,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
if (evt.target?.result) {
|
|
setInvoiceLogoUrl(evt.target.result as string);
|
|
toast.success('Invoice logo uploaded');
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
const relativePath =
|
|
data.webp_path || data.storage_path || data.thumbnail_path || data.raw_path || data.url || data.file_path || '';
|
|
const uploadedUrl = relativePath.startsWith('http')
|
|
? relativePath
|
|
: `${backendUrl}${relativePath.startsWith('/') ? '' : '/'}${relativePath}`;
|
|
setInvoiceLogoUrl(uploadedUrl);
|
|
toast.success('Invoice logo uploaded');
|
|
} catch {
|
|
const reader = new FileReader();
|
|
reader.onload = (evt) => {
|
|
if (evt.target?.result) {
|
|
setInvoiceLogoUrl(evt.target.result as string);
|
|
toast.success('Invoice logo loaded');
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
} finally {
|
|
setUploadingLogo(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveInvoiceBranding = async () => {
|
|
const brandingData = {
|
|
logoUrl: invoiceLogoUrl,
|
|
companyName: invoiceCompanyName,
|
|
gstin: invoiceGstin,
|
|
storeAddress: invoiceStoreAddress,
|
|
gstRate: Number(invoiceGstRate) || 18.0,
|
|
};
|
|
localStorage.setItem('ifixkart_invoice_branding', JSON.stringify(brandingData));
|
|
|
|
try {
|
|
const token = getAccessToken();
|
|
const backendUrl = '';
|
|
await fetch(`${backendUrl}/api/v1/settings/save/invoice_branding`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
body: JSON.stringify({
|
|
setting_value: brandingData,
|
|
description: 'Invoice logo and store branding configurations',
|
|
is_public: true,
|
|
}),
|
|
});
|
|
} catch {
|
|
/* save still persisted locally */
|
|
}
|
|
|
|
toast.success('Invoice logo and branding saved');
|
|
};
|
|
|
|
const copyUserId = async () => {
|
|
if (!profile?.user_id) return;
|
|
try {
|
|
await navigator.clipboard.writeText(profile.user_id);
|
|
setCopiedId(true);
|
|
toast.success('User ID copied to clipboard');
|
|
setTimeout(() => setCopiedId(false), 1500);
|
|
} catch {
|
|
toast.error('Could not copy the user ID');
|
|
}
|
|
};
|
|
|
|
const reloadAll = () => {
|
|
fetchUserProfile();
|
|
fetchBrandingSettings();
|
|
};
|
|
|
|
const navItems = useMemo(
|
|
() =>
|
|
[
|
|
{ id: 'overview' as const, label: 'Overview', hint: 'Resource essentials', icon: LayoutDashboard },
|
|
{ id: 'profile' as const, label: 'Identity', hint: 'Name, phone, username', icon: User },
|
|
{ id: 'security' as const, label: 'Access', hint: 'Password credentials', icon: KeyRound },
|
|
...(isSuperAdmin
|
|
? [{ id: 'branding' as const, label: 'Invoice branding', hint: 'Logo, GSTIN, store', icon: FileText }]
|
|
: []),
|
|
].filter((item) => {
|
|
const q = navSearch.trim().toLowerCase();
|
|
if (!q) return true;
|
|
return item.label.toLowerCase().includes(q) || item.hint.toLowerCase().includes(q);
|
|
}),
|
|
[isSuperAdmin, navSearch]
|
|
);
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const inputClass =
|
|
'w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 whitespace-nowrap';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50 whitespace-nowrap';
|
|
|
|
const fullName = [firstName, lastName].filter(Boolean).join(' ') || profile?.email || 'Administrator';
|
|
const roleLabel = userRole ? userRole.replace(/_/g, ' ') : 'Admin';
|
|
|
|
const bladeMeta: Record<SettingsBlade, { title: string; subtitle: string }> = {
|
|
overview: {
|
|
title: 'Overview',
|
|
subtitle: 'Account resource essentials and current identity status.',
|
|
},
|
|
profile: {
|
|
title: 'Identity',
|
|
subtitle: 'Update the display credentials used across the admin console.',
|
|
},
|
|
security: {
|
|
title: 'Access credentials',
|
|
subtitle: 'Rotate the password used to sign in to this administrator account.',
|
|
},
|
|
branding: {
|
|
title: 'Invoice branding',
|
|
subtitle: 'Logo, legal name, GSTIN and store contact printed on invoices.',
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">System Settings</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none capitalize">
|
|
{roleLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={reloadAll}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loadingProfile ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`${dataCardShell} p-4`}>
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 min-w-0">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<div className="w-11 h-11 crm-radius-icon bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Settings className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-[15px] font-semibold text-foreground truncate">{fullName}</p>
|
|
<p className="text-[13px] text-muted-foreground truncate">
|
|
{email || profile?.email || 'Administrator account'} · iFixKart console
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[11px] font-semibold ${
|
|
profile?.is_active !== false ? 'bg-success text-white' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{profile?.is_active !== false ? 'Active' : 'Inactive'}
|
|
</span>
|
|
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge bg-muted text-foreground text-[11px] font-semibold capitalize">
|
|
{roleLabel}
|
|
</span>
|
|
{profile?.email_verified && (
|
|
<span className="inline-flex items-center px-2 py-0.5 crm-radius-badge bg-success/10 text-success text-[11px] font-semibold">
|
|
Email verified
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-[240px_minmax(0,1fr)] gap-4 min-w-0 items-start">
|
|
<aside className={`${dataCardShell} p-3 xl:sticky xl:top-5`}>
|
|
<div className="relative mb-2">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
value={navSearch}
|
|
onChange={(e) => setNavSearch(e.target.value)}
|
|
placeholder="Filter settings"
|
|
className="w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
<nav className="flex flex-col gap-1">
|
|
{navItems.length === 0 ? (
|
|
<p className="px-3 py-6 text-center text-[12px] text-muted-foreground">No matching settings.</p>
|
|
) : (
|
|
navItems.map((item) => {
|
|
const Icon = item.icon;
|
|
const selected = blade === item.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setBlade(item.id)}
|
|
className={`relative w-full flex items-center gap-2.5 h-auto min-h-10 px-3 py-2 text-left crm-radius-toggle cursor-pointer transition-colors ${
|
|
selected
|
|
? 'bg-card border border-[#b8c0d4] text-foreground'
|
|
: 'border border-transparent text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
{selected && <span className="absolute left-0 top-1.5 bottom-1.5 w-[3px] bg-primary" />}
|
|
<span
|
|
className={`w-7 h-7 crm-radius-icon flex items-center justify-center shrink-0 ${
|
|
selected ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
</span>
|
|
<span className="min-w-0 overflow-hidden">
|
|
<span className="block text-[13px] font-semibold leading-5 truncate">{item.label}</span>
|
|
<span className="block text-[12px] text-muted-foreground mt-0.5 truncate">{item.hint}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</nav>
|
|
</aside>
|
|
|
|
<section className={`${dataCardShell} overflow-hidden`}>
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 px-5 py-4 border-b border-border min-w-0">
|
|
<div className="min-w-0">
|
|
<h2 className="text-[16px] font-semibold text-foreground">{bladeMeta[blade].title}</h2>
|
|
<p className="text-[13px] text-muted-foreground mt-1">{bladeMeta[blade].subtitle}</p>
|
|
</div>
|
|
{blade === 'profile' && (
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button type="button" onClick={handleDiscardProfile} className={secondaryButton}>
|
|
Discard
|
|
</button>
|
|
<button type="submit" form="settings-profile-form" disabled={savingProfile} className={primaryButton}>
|
|
{savingProfile ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
Save
|
|
</button>
|
|
</div>
|
|
)}
|
|
{blade === 'security' && (
|
|
<button type="submit" form="settings-security-form" disabled={savingPassword} className={primaryButton}>
|
|
{savingPassword ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Lock className="w-3.5 h-3.5" />}
|
|
Update password
|
|
</button>
|
|
)}
|
|
{blade === 'branding' && isSuperAdmin && (
|
|
<button type="button" onClick={handleSaveInvoiceBranding} className={primaryButton}>
|
|
<Save className="w-3.5 h-3.5" />
|
|
Save branding
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-5 min-w-0 overflow-hidden">
|
|
{loadingProfile && blade !== 'branding' ? (
|
|
<div className="py-16 flex justify-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : blade === 'overview' ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-6 min-w-0">
|
|
<dl className="min-w-0 overflow-hidden">
|
|
<p className="text-[14px] font-semibold text-foreground mb-1">Essentials</p>
|
|
<PropertyRow label="Resource">{fullName}</PropertyRow>
|
|
<PropertyRow label="User name">{userName || '—'}</PropertyRow>
|
|
<PropertyRow label="Email">{email || '—'}</PropertyRow>
|
|
<PropertyRow label="Phone">{phone || '—'}</PropertyRow>
|
|
<PropertyRow label="Role">
|
|
<span className="block truncate capitalize">{roleLabel}</span>
|
|
</PropertyRow>
|
|
</dl>
|
|
<dl className="min-w-0 overflow-hidden">
|
|
<p className="text-[14px] font-semibold text-foreground mb-1">Directory</p>
|
|
<PropertyRow label="User ID">
|
|
<div className="flex items-center gap-2 min-w-0 w-full">
|
|
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[13px]" title={profile?.user_id}>
|
|
{profile?.user_id || '—'}
|
|
</span>
|
|
{profile?.user_id && (
|
|
<button
|
|
type="button"
|
|
title="Copy user ID"
|
|
onClick={copyUserId}
|
|
className="w-7 h-7 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer flex items-center justify-center shrink-0"
|
|
>
|
|
{copiedId ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</PropertyRow>
|
|
<PropertyRow label="Employee code">{profile?.employee_code || '—'}</PropertyRow>
|
|
<PropertyRow label="Last login">
|
|
{profile?.last_login ? format(new Date(profile.last_login), 'dd MMM yyyy, hh:mm a') : '—'}
|
|
</PropertyRow>
|
|
<PropertyRow label="Created">
|
|
{profile?.created_at ? format(new Date(profile.created_at), 'dd MMM yyyy') : '—'}
|
|
</PropertyRow>
|
|
<PropertyRow label="Status">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 crm-radius-badge text-[12px] font-semibold ${
|
|
profile?.is_active !== false ? 'bg-success text-white' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{profile?.is_active !== false ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</PropertyRow>
|
|
</dl>
|
|
<div className="lg:col-span-2 flex flex-wrap gap-2 pt-1">
|
|
<button type="button" onClick={() => setBlade('profile')} className={secondaryButton}>
|
|
<User className="w-3.5 h-3.5" />
|
|
Edit identity
|
|
</button>
|
|
<button type="button" onClick={() => setBlade('security')} className={secondaryButton}>
|
|
<Shield className="w-3.5 h-3.5" />
|
|
Manage access
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : blade === 'profile' ? (
|
|
<form id="settings-profile-form" onSubmit={handleSaveProfile} className="space-y-4 max-w-3xl">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelClass}>First name</label>
|
|
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Last name</label>
|
|
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Username</label>
|
|
<input type="text" value={userName} onChange={(e) => setUserName(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Phone number</label>
|
|
<input type="text" value={phone} onChange={(e) => setPhone(e.target.value)} className={inputClass} />
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className={labelClass}>Email address</label>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
disabled
|
|
className={`${inputClass} bg-muted text-muted-foreground cursor-not-allowed`}
|
|
/>
|
|
<p className="text-[13px] text-muted-foreground mt-1.5">Email is bound to this directory account and cannot be changed here.</p>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
) : blade === 'security' ? (
|
|
<form id="settings-security-form" onSubmit={handleUpdatePassword} className="space-y-4 max-w-3xl">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="md:col-span-2">
|
|
<label className={labelClass}>Current password</label>
|
|
<input
|
|
type="password"
|
|
placeholder="••••••••"
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>New password</label>
|
|
<input
|
|
type="password"
|
|
placeholder="••••••••"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Confirm new password</label>
|
|
<input
|
|
type="password"
|
|
placeholder="••••••••"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="text-[13px] text-muted-foreground">
|
|
Updating the password signs this administrator in with the new credential on the next login.
|
|
</p>
|
|
</form>
|
|
) : (
|
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_260px] gap-5 min-w-0">
|
|
<div className="space-y-4 min-w-0">
|
|
<div>
|
|
<label className={labelClass}>Company / invoice brand logo</label>
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
{invoiceLogoUrl ? (
|
|
<div className="w-16 h-16 crm-radius-icon border border-border bg-card p-1.5 flex items-center justify-center overflow-hidden shrink-0">
|
|
<img src={invoiceLogoUrl} alt="Invoice logo preview" className="max-w-full max-h-full object-contain" />
|
|
</div>
|
|
) : (
|
|
<div className="w-16 h-16 crm-radius-icon border border-dashed border-border bg-muted/20 flex flex-col items-center justify-center shrink-0 text-muted-foreground">
|
|
<ImageIcon className="w-5 h-5" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1 min-w-0 space-y-2">
|
|
<input
|
|
type="text"
|
|
value={invoiceLogoUrl}
|
|
onChange={(e) => setInvoiceLogoUrl(e.target.value)}
|
|
placeholder="https://ifixkart.com/logo.png"
|
|
className={inputClass}
|
|
/>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
id="invoiceLogoFileInput"
|
|
className="hidden"
|
|
onChange={handleLogoFileUpload}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={uploadingLogo}
|
|
onClick={() => document.getElementById('invoiceLogoFileInput')?.click()}
|
|
className={secondaryButton}
|
|
>
|
|
{uploadingLogo ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
|
|
{uploadingLogo ? 'Uploading...' : 'Upload logo'}
|
|
</button>
|
|
<p className="text-[13px] text-muted-foreground">PNG, JPG, WebP or SVG. Transparent logos print cleanest.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Company legal name</label>
|
|
<input
|
|
type="text"
|
|
value={invoiceCompanyName}
|
|
onChange={(e) => setInvoiceCompanyName(e.target.value)}
|
|
placeholder="iFixKart Solutions Platform"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>GSTIN / tax registration no.</label>
|
|
<input
|
|
type="text"
|
|
value={invoiceGstin}
|
|
onChange={(e) => setInvoiceGstin(e.target.value)}
|
|
placeholder="33AAAAA0000A1Z5"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Default GST / Tax Rate (%)</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
step="0.1"
|
|
min="0"
|
|
max="100"
|
|
value={invoiceGstRate}
|
|
onChange={(e) => setInvoiceGstRate(e.target.value)}
|
|
placeholder="18.0"
|
|
className={inputClass}
|
|
/>
|
|
<span className="text-[13px] font-semibold text-muted-foreground">%</span>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-1">
|
|
Configures default GST split on generated tax invoices (e.g. 18% = CGST {(Number(invoiceGstRate) / 2 || 9)}% + SGST {(Number(invoiceGstRate) / 2 || 9)}%).
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Store address & contact</label>
|
|
<input
|
|
type="text"
|
|
value={invoiceStoreAddress}
|
|
onChange={(e) => setInvoiceStoreAddress(e.target.value)}
|
|
placeholder="Offline Main Store Counter, Chennai | +91 9876543210"
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="crm-radius-section border border-border bg-muted/20 p-4 min-w-0 overflow-hidden h-fit">
|
|
<p className="text-[13px] font-semibold text-foreground mb-3">Invoice preview</p>
|
|
<div className="crm-radius-section border border-border bg-card p-4 min-w-0 overflow-hidden">
|
|
<div className="flex items-center gap-2 pb-3 border-b border-border min-w-0">
|
|
{invoiceLogoUrl ? (
|
|
<img src={invoiceLogoUrl} alt="" className="h-8 w-8 object-contain shrink-0" />
|
|
) : (
|
|
<Building className="w-5 h-5 text-muted-foreground shrink-0" />
|
|
)}
|
|
<div className="min-w-0 overflow-hidden">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">{invoiceCompanyName || 'Company name'}</p>
|
|
<p className="text-[12px] text-muted-foreground truncate">Tax invoice</p>
|
|
</div>
|
|
</div>
|
|
<dl className="mt-3 space-y-2 text-[13px] min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<dt className="text-muted-foreground shrink-0">GSTIN</dt>
|
|
<dd className="font-mono font-semibold text-foreground min-w-0 truncate">{invoiceGstin || '—'}</dd>
|
|
</div>
|
|
<div className="min-w-0">
|
|
<dt className="text-muted-foreground">Store</dt>
|
|
<dd className="font-medium text-foreground mt-0.5 leading-5 break-words">{invoiceStoreAddress || '—'}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|