first commit
This commit is contained in:
58
src/features/settings/components/AccountCard.tsx
Normal file
58
src/features/settings/components/AccountCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
|
||||
/**
|
||||
* Who you are signed in as.
|
||||
*
|
||||
* Read from the SESSION rather than fetched: it is the same object the proxy
|
||||
* verified and the server rendered with, so this cannot disagree with what the
|
||||
* rest of the console believes about the current user, and it costs no request.
|
||||
*
|
||||
* A plain Card rather than PanelCard because there is nothing async here —
|
||||
* PanelCard exists to wrap a Resource, and passing it a fake one to reuse the
|
||||
* header would be the tail wagging the dog.
|
||||
*
|
||||
* There is no edit: a merchant cannot change their own role, and the platform
|
||||
* serves no self-service profile endpoint.
|
||||
*/
|
||||
export function AccountCard() {
|
||||
const {user} = useSession();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={4}>
|
||||
<SectionHeader
|
||||
title="Account"
|
||||
subtitle="The account this browser is signed in as."
|
||||
level={2}
|
||||
/>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<Avatar name={user.name} size="md" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{user.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{user.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge variant="neutral" label={user.role} />
|
||||
{user.organisation ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{user.organisation}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
383
src/features/settings/components/ApiWebhooksManager.tsx
Normal file
383
src/features/settings/components/ApiWebhooksManager.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface ApiKeyItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
created: string;
|
||||
lastUsed: string;
|
||||
status: 'active' | 'revoked';
|
||||
}
|
||||
|
||||
export interface WebhookEndpointItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string;
|
||||
secret: string;
|
||||
status: 'healthy' | 'failing';
|
||||
}
|
||||
|
||||
export interface WebhookLogItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
event: string;
|
||||
url: string;
|
||||
code: number;
|
||||
latency: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const INITIAL_KEYS: ApiKeyItem[] = [
|
||||
{
|
||||
id: 'key-1',
|
||||
name: 'Production POS Key',
|
||||
keyPrefix: 'lmer_live_9f8a••••••••4b12',
|
||||
created: '10 Jan 2026',
|
||||
lastUsed: 'Just now',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'key-2',
|
||||
name: 'Staging Integration Key',
|
||||
keyPrefix: 'lmer_test_3d2c••••••••8e91',
|
||||
created: '15 Jun 2026',
|
||||
lastUsed: 'Yesterday',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
|
||||
const INITIAL_WEBHOOKS: WebhookEndpointItem[] = [
|
||||
{
|
||||
id: 'wh-1',
|
||||
url: 'https://api.nearle.in/webhooks/loyaly',
|
||||
events: 'order.created, lyt.redeemed',
|
||||
secret: 'whsec_8841••••••••9932',
|
||||
status: 'healthy',
|
||||
},
|
||||
{
|
||||
id: 'wh-2',
|
||||
url: 'https://hooks.zapier.com/hooks/catch/19284',
|
||||
events: 'staff.checkin, store.updated',
|
||||
secret: 'whsec_7712••••••••1102',
|
||||
status: 'healthy',
|
||||
},
|
||||
];
|
||||
|
||||
const WEBHOOK_LOGS: WebhookLogItem[] = [
|
||||
{
|
||||
id: 'log-101',
|
||||
event: 'lyt.redeemed',
|
||||
url: 'https://api.nearle.in/webhooks/loyaly',
|
||||
code: 200,
|
||||
latency: '142ms',
|
||||
timestamp: 'Today, 15:42:01',
|
||||
},
|
||||
{
|
||||
id: 'log-102',
|
||||
event: 'order.created',
|
||||
url: 'https://api.nearle.in/webhooks/loyaly',
|
||||
code: 200,
|
||||
latency: '185ms',
|
||||
timestamp: 'Today, 15:38:12',
|
||||
},
|
||||
{
|
||||
id: 'log-103',
|
||||
event: 'staff.checkin',
|
||||
url: 'https://hooks.zapier.com/hooks/catch/19284',
|
||||
code: 200,
|
||||
latency: '95ms',
|
||||
timestamp: 'Today, 09:01:44',
|
||||
},
|
||||
];
|
||||
|
||||
export function ApiWebhooksManager() {
|
||||
const toast = useToast();
|
||||
const [keys, setKeys] = useState<ApiKeyItem[]>(INITIAL_KEYS);
|
||||
const [webhooks, setWebhooks] = useState<WebhookEndpointItem[]>(INITIAL_WEBHOOKS);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [newWebhookUrl, setNewWebhookUrl] = useState('');
|
||||
|
||||
const handleGenerateKey = () => {
|
||||
if (!newKeyName.trim()) {
|
||||
toast({type: 'error', body: 'Key name is required'});
|
||||
return;
|
||||
}
|
||||
const createdKey: ApiKeyItem = {
|
||||
id: `key-${Date.now()}`,
|
||||
name: newKeyName,
|
||||
keyPrefix: `lmer_live_${Math.random().toString(36).substring(2, 6)}••••••••${Math.random().toString(36).substring(2, 6)}`,
|
||||
created: 'Just now',
|
||||
lastUsed: 'Never',
|
||||
status: 'active',
|
||||
};
|
||||
setKeys((prev) => [createdKey, ...prev]);
|
||||
setNewKeyName('');
|
||||
setIsGenerating(false);
|
||||
toast({body: `API Key "${newKeyName}" generated successfully`});
|
||||
};
|
||||
|
||||
const handleRevokeKey = (id: string, name: string) => {
|
||||
setKeys((prev) => prev.filter((k) => k.id !== id));
|
||||
toast({body: `Revoked API Key "${name}"`});
|
||||
};
|
||||
|
||||
const handleAddWebhook = () => {
|
||||
if (!newWebhookUrl.trim()) {
|
||||
toast({type: 'error', body: 'Webhook URL is required'});
|
||||
return;
|
||||
}
|
||||
const newWh: WebhookEndpointItem = {
|
||||
id: `wh-${Date.now()}`,
|
||||
url: newWebhookUrl,
|
||||
events: 'order.created, lyt.redeemed, staff.checkin',
|
||||
secret: `whsec_${Math.random().toString(36).substring(2, 8)}••••`,
|
||||
status: 'healthy',
|
||||
};
|
||||
setWebhooks((prev) => [newWh, ...prev]);
|
||||
setNewWebhookUrl('');
|
||||
toast({body: 'Webhook endpoint registered successfully'});
|
||||
};
|
||||
|
||||
const keyColumns: TableColumn<ApiKeyItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Key Name',
|
||||
width: proportional(1.5),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.keyPrefix}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
header: 'Created Date',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => <Text size="sm">{row.created}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'lastUsed',
|
||||
header: 'Last Active',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.lastUsed}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status === 'active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Action',
|
||||
align: 'center',
|
||||
width: pixel(120),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Revoke"
|
||||
onClick={() => handleRevokeKey(row.id, row.name)}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const webhookColumns: TableColumn<WebhookEndpointItem>[] = [
|
||||
{
|
||||
key: 'url',
|
||||
header: 'Endpoint URL',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.url}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Events: {row.events}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'secret',
|
||||
header: 'Signing Secret',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.secret}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Health',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status === 'healthy' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const logColumns: TableColumn<WebhookLogItem>[] = [
|
||||
{
|
||||
key: 'event',
|
||||
header: 'Event',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.event}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'url',
|
||||
header: 'Target URL',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.url}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
header: 'HTTP Code',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.code === 200 ? 'success' : 'error'}
|
||||
label={`${row.code} OK`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'latency',
|
||||
header: 'Latency',
|
||||
width: pixel(90),
|
||||
renderCell: (row) => <Text size="sm">{row.latency}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.timestamp}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Developer API Keys"
|
||||
subtitle="Manage authentication tokens for custom POS clients and server SDKs."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label={isGenerating ? 'Cancel' : 'Generate Secret Key'}
|
||||
onClick={() => setIsGenerating(!isGenerating)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
{isGenerating ? (
|
||||
<HStack gap={3} vAlign="end" wrap="wrap">
|
||||
<TextInput
|
||||
label="API Key Description"
|
||||
value={newKeyName}
|
||||
onChange={setNewKeyName}
|
||||
placeholder="e.g. Indiranagar Counter 2 POS"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Generate Token"
|
||||
onClick={handleGenerateKey}
|
||||
/>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<ResponsiveTable
|
||||
columns={keyColumns}
|
||||
data={keys}
|
||||
primaryKey="name"
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Webhook Subscriptions & Endpoints"
|
||||
subtitle="Real-time HTTP POST callbacks for order, redemption and staff events."
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<HStack gap={3} vAlign="end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Add Webhook Endpoint URL"
|
||||
value={newWebhookUrl}
|
||||
onChange={setNewWebhookUrl}
|
||||
placeholder="https://yourdomain.com/webhooks/loyaly"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Add Endpoint"
|
||||
onClick={handleAddWebhook}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<ResponsiveTable
|
||||
columns={webhookColumns}
|
||||
data={webhooks}
|
||||
primaryKey="url"
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Webhook Delivery Logs"
|
||||
subtitle="Recent HTTP delivery attempts, latency and status response codes."
|
||||
>
|
||||
<ResponsiveTable
|
||||
columns={logColumns}
|
||||
data={WEBHOOK_LOGS}
|
||||
primaryKey="event"
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
229
src/features/settings/components/BillingOverview.tsx
Normal file
229
src/features/settings/components/BillingOverview.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
|
||||
import {formatInr} from '@/shared/utils/format';
|
||||
|
||||
export interface InvoiceItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
status: 'paid' | 'pending';
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
const INVOICES: InvoiceItem[] = [
|
||||
{
|
||||
id: 'INV-2026-008',
|
||||
date: '1 Aug 2026',
|
||||
amount: 14999,
|
||||
status: 'paid',
|
||||
downloadUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 'INV-2026-007',
|
||||
date: '1 Jul 2026',
|
||||
amount: 14999,
|
||||
status: 'paid',
|
||||
downloadUrl: '#',
|
||||
},
|
||||
{
|
||||
id: 'INV-2026-006',
|
||||
date: '1 Jun 2026',
|
||||
amount: 14999,
|
||||
status: 'paid',
|
||||
downloadUrl: '#',
|
||||
},
|
||||
];
|
||||
|
||||
import {DownloadDropdown} from '@/shared/components/patterns/DownloadDropdown';
|
||||
|
||||
export function BillingOverview() {
|
||||
const toast = useToast();
|
||||
const [bankAccount, setBankAccount] = useState('HDFC Bank •••• 8842');
|
||||
const [ifsc, setIfsc] = useState('HDFC0001234');
|
||||
const [isUpdatingBank, setIsUpdatingBank] = useState(false);
|
||||
|
||||
const handleUpdateBank = () => {
|
||||
setIsUpdatingBank(true);
|
||||
setTimeout(() => {
|
||||
setIsUpdatingBank(false);
|
||||
toast({body: 'Settlement bank account details updated'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const columns: TableColumn<InvoiceItem>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
header: 'Invoice ID',
|
||||
width: proportional(1.5),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.id}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Billed Date',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.date}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Amount',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{formatInr(row.amount)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status === 'paid' ? 'success' : 'warning'}
|
||||
label={row.status}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Statement',
|
||||
align: 'end',
|
||||
width: pixel(180),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="end">
|
||||
<DownloadDropdown
|
||||
filename={`Invoice_${row.id}`}
|
||||
title={`Invoice Statement ${row.id}`}
|
||||
subtitle={`Billed Date: ${row.date} • Amount: ₹${row.amount}`}
|
||||
columns={[
|
||||
{key: 'id', header: 'Invoice ID'},
|
||||
{key: 'date', header: 'Billed Date'},
|
||||
{key: 'amount', header: 'Amount (₹)'},
|
||||
{key: 'status', header: 'Payment Status'},
|
||||
]}
|
||||
data={[row]}
|
||||
variant="primary"
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Subscription & Quota Usage"
|
||||
subtitle="Enterprise Growth Plan details, active limit quotas and payment instruments."
|
||||
actions={<Badge variant="success" label="Subscription Active" />}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<StatRow>
|
||||
<StatPair label="Active Plan" value="Growth Enterprise" />
|
||||
<StatPair label="Billing Frequency" value="Monthly" />
|
||||
<StatPair label="Next Renewal" value="1 Sep 2026" />
|
||||
<StatPair label="Recurring Amount" value={formatInr(14999)} align="end" />
|
||||
</StatRow>
|
||||
|
||||
<Divider />
|
||||
|
||||
<VStack gap={3}>
|
||||
<Heading level={3}>Usage Quotas</Heading>
|
||||
<VStack gap={2}>
|
||||
<HStack hAlign="between" vAlign="center">
|
||||
<Text size="sm">Store Locations (5 of 10 used)</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
50%
|
||||
</Text>
|
||||
</HStack>
|
||||
<ProgressBar value={50} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2}>
|
||||
<HStack hAlign="between" vAlign="center">
|
||||
<Text size="sm">Staff User Seats (5 of 15 used)</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
33%
|
||||
</Text>
|
||||
</HStack>
|
||||
<ProgressBar value={33} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2}>
|
||||
<HStack hAlign="between" vAlign="center">
|
||||
<Text size="sm">API Calls / Month (142,500 of 500,000)</Text>
|
||||
<Text size="sm" weight="medium">
|
||||
28.5%
|
||||
</Text>
|
||||
</HStack>
|
||||
<ProgressBar value={28.5} max={100} label="Usage" isLabelHidden hasValueLabel={false} />
|
||||
</VStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="LYT Settlement Bank Details"
|
||||
subtitle="LYTs redeemed at store counters are settled directly into your registered bank account (1 LYT = ₹1)."
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Save Bank Info"
|
||||
isLoading={isUpdatingBank}
|
||||
onClick={handleUpdateBank}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<HStack gap={4} wrap="wrap">
|
||||
<TextInput
|
||||
label="Settlement Bank & Account"
|
||||
value={bankAccount}
|
||||
onChange={setBankAccount}
|
||||
/>
|
||||
<TextInput
|
||||
label="IFSC Code"
|
||||
value={ifsc}
|
||||
onChange={setIfsc}
|
||||
/>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
Settlement dispatches take place every Monday morning at 06:00 IST. Net liability is automatically credited.
|
||||
</Text>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel title="Invoice Statements & Payment History" subtitle="Download tax invoices and monthly receipts.">
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={INVOICES}
|
||||
primaryKey="id"
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
175
src/features/settings/components/BusinessForm.tsx
Normal file
175
src/features/settings/components/BusinessForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface BusinessData {
|
||||
businessName: string;
|
||||
legalEntity: string;
|
||||
category: string;
|
||||
gstin: string;
|
||||
pan: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
city: string;
|
||||
pincode: string;
|
||||
lytsPerHundred: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BUSINESS: BusinessData = {
|
||||
businessName: 'Loyaly Retail Pvt Ltd',
|
||||
legalEntity: 'Private Limited Company',
|
||||
category: 'Retail & Quick Service Restaurant',
|
||||
gstin: '29AABCL1234M1Z7',
|
||||
pan: 'AABCL1234M',
|
||||
phone: '+91 98450 12345',
|
||||
email: 'aravind@nearle.in',
|
||||
address: '100 Feet Road, Indiranagar',
|
||||
city: 'Bengaluru, Karnataka',
|
||||
pincode: '560038',
|
||||
lytsPerHundred: 5,
|
||||
};
|
||||
|
||||
export function BusinessForm() {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<BusinessData>(DEFAULT_BUSINESS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
|
||||
|
||||
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
|
||||
setForm((f) => ({...f, [key]: val}));
|
||||
|
||||
const isDirty = JSON.stringify(form) !== JSON.stringify(savedData);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
setSavedData(form);
|
||||
setIsSaving(false);
|
||||
toast({body: 'Business details updated successfully'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Business Information"
|
||||
subtitle="Official company details, tax registration and operating address."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => setForm(savedData)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save changes"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Business name"
|
||||
value={form.businessName}
|
||||
onChange={(v) => set('businessName', v)}
|
||||
isRequired
|
||||
/>
|
||||
<Selector
|
||||
label="Legal entity structure"
|
||||
value={form.legalEntity}
|
||||
onChange={(v) => set('legalEntity', v)}
|
||||
options={[
|
||||
'Private Limited Company',
|
||||
'Sole Proprietorship',
|
||||
'Partnership Firm',
|
||||
'Limited Liability Partnership (LLP)',
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="GSTIN"
|
||||
value={form.gstin}
|
||||
onChange={(v) => set('gstin', v.toUpperCase())}
|
||||
description="15-digit Tax Registration Number"
|
||||
/>
|
||||
<TextInput
|
||||
label="PAN"
|
||||
value={form.pan}
|
||||
onChange={(v) => set('pan', v.toUpperCase())}
|
||||
description="10-digit Permanent Account Number"
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Support email"
|
||||
value={form.email}
|
||||
onChange={(v) => set('email', v)}
|
||||
isRequired
|
||||
/>
|
||||
<TextInput
|
||||
label="Support phone"
|
||||
value={form.phone}
|
||||
onChange={(v) => set('phone', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Registered address"
|
||||
value={form.address}
|
||||
onChange={(v) => set('address', v)}
|
||||
/>
|
||||
<TextInput
|
||||
label="City & State"
|
||||
value={form.city}
|
||||
onChange={(v) => set('city', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Pincode"
|
||||
value={form.pincode}
|
||||
onChange={(v) => set('pincode', v)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Default LYTs per ₹100 spent"
|
||||
value={form.lytsPerHundred}
|
||||
onChange={(v) => set('lytsPerHundred', v ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
description="1 LYT = ₹1. Basket ₹1,000 earns 50 LYTs (5%)."
|
||||
/>
|
||||
</FormLayout>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
213
src/features/settings/components/IntegrationsGrid.tsx
Normal file
213
src/features/settings/components/IntegrationsGrid.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface IntegrationApp {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
status: 'connected' | 'disconnected' | 'syncing';
|
||||
apiKeyLabel?: string;
|
||||
lastSync?: string;
|
||||
}
|
||||
|
||||
const INITIAL_APPS: IntegrationApp[] = [
|
||||
{
|
||||
id: 'shopify',
|
||||
name: 'Shopify Storefront',
|
||||
category: 'E-commerce POS',
|
||||
description: 'Sync online customer orders, cart totals and auto-issue LYT points.',
|
||||
status: 'connected',
|
||||
apiKeyLabel: 'myshopify-store.myshopify.com',
|
||||
lastSync: '5 mins ago',
|
||||
},
|
||||
{
|
||||
id: 'woocommerce',
|
||||
name: 'WooCommerce',
|
||||
category: 'E-commerce POS',
|
||||
description: 'WordPress store integration for online checkout redemption.',
|
||||
status: 'disconnected',
|
||||
},
|
||||
{
|
||||
id: 'razorpay',
|
||||
name: 'Razorpay PG',
|
||||
category: 'Payment Gateway',
|
||||
description: 'Automated settlement reconciliation and online UPI payment sync.',
|
||||
status: 'connected',
|
||||
apiKeyLabel: 'rzp_live_••••••••8841',
|
||||
lastSync: 'Just now',
|
||||
},
|
||||
{
|
||||
id: 'stripe',
|
||||
name: 'Stripe Billing',
|
||||
category: 'Payment Gateway',
|
||||
description: 'International cards and subscription recurring billing handler.',
|
||||
status: 'disconnected',
|
||||
},
|
||||
{
|
||||
id: 'whatsapp',
|
||||
name: 'WhatsApp Business API',
|
||||
category: 'Messaging Channel',
|
||||
description: 'Send instant transaction receipts, reward expiry notices and OTPs.',
|
||||
status: 'connected',
|
||||
apiKeyLabel: '+91 98450 12345 (Verified)',
|
||||
lastSync: '2 mins ago',
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google Business Profile',
|
||||
category: 'Local Search & Maps',
|
||||
description: 'Sync branch operating hours, store locations and customer reviews.',
|
||||
status: 'connected',
|
||||
apiKeyLabel: '5 locations synced',
|
||||
lastSync: '1 hour ago',
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
name: 'Meta Ads & Conversions',
|
||||
category: 'Marketing & Ads',
|
||||
description: 'Track ad attribution, return on ad spend (ROAS) and retargeting.',
|
||||
status: 'disconnected',
|
||||
},
|
||||
];
|
||||
|
||||
export function IntegrationsGrid() {
|
||||
const toast = useToast();
|
||||
const [apps, setApps] = useState<IntegrationApp[]>(INITIAL_APPS);
|
||||
const [selectedApp, setSelectedApp] = useState<IntegrationApp | null>(null);
|
||||
const [apiKeyInput, setApiKeyInput] = useState('');
|
||||
|
||||
const toggleConnection = (app: IntegrationApp) => {
|
||||
if (app.status === 'connected') {
|
||||
setApps((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === app.id ? {...a, status: 'disconnected', lastSync: undefined} : a,
|
||||
),
|
||||
);
|
||||
toast({body: `Disconnected ${app.name}`});
|
||||
} else {
|
||||
setSelectedApp(app);
|
||||
setApiKeyInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmConnect = () => {
|
||||
if (!selectedApp) return;
|
||||
setApps((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === selectedApp.id
|
||||
? {
|
||||
...a,
|
||||
status: 'connected',
|
||||
apiKeyLabel: apiKeyInput || 'Configured',
|
||||
lastSync: 'Just now',
|
||||
}
|
||||
: a,
|
||||
),
|
||||
);
|
||||
toast({body: `Successfully connected ${selectedApp.name}`});
|
||||
setSelectedApp(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Third-Party Integrations & Connectors"
|
||||
subtitle="Connect E-commerce storefronts, payment gateways, messaging services and advertising platforms."
|
||||
>
|
||||
<VStack gap={4}>
|
||||
{selectedApp ? (
|
||||
<Card variant="muted">
|
||||
<VStack gap={3}>
|
||||
<Heading level={3}>Connect {selectedApp.name}</Heading>
|
||||
<Text size="sm" color="secondary">
|
||||
{selectedApp.description}
|
||||
</Text>
|
||||
<TextInput
|
||||
label="API Key / Store URL / Identifier"
|
||||
value={apiKeyInput}
|
||||
onChange={setApiKeyInput}
|
||||
placeholder="Paste live secret key or domain URL"
|
||||
/>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Establish Connection"
|
||||
onClick={handleConfirmConnect}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Cancel"
|
||||
onClick={() => setSelectedApp(null)}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Grid columns={{minWidth: 320, max: 2, repeat: 'fit'}} gap={4}>
|
||||
{apps.map((app) => {
|
||||
const isConn = app.status === 'connected';
|
||||
return (
|
||||
<Card key={app.id}>
|
||||
<VStack gap={3}>
|
||||
<HStack hAlign="between" vAlign="start">
|
||||
<VStack gap={0.5}>
|
||||
<Text weight="medium" size="sm">
|
||||
{app.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{app.category}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Badge
|
||||
variant={isConn ? 'success' : 'neutral'}
|
||||
label={isConn ? 'Connected' : 'Disconnected'}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Text size="sm" color="secondary">
|
||||
{app.description}
|
||||
</Text>
|
||||
|
||||
{isConn && app.apiKeyLabel ? (
|
||||
<HStack hAlign="between" vAlign="center">
|
||||
<Text size="sm" color="secondary">
|
||||
{app.apiKeyLabel}
|
||||
</Text>
|
||||
{app.lastSync ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Synced {app.lastSync}
|
||||
</Text>
|
||||
) : null}
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant={isConn ? 'secondary' : 'primary'}
|
||||
size="sm"
|
||||
label={isConn ? 'Disconnect' : 'Connect & Configure'}
|
||||
onClick={() => toggleConnection(app)}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
157
src/features/settings/components/NotificationsForm.tsx
Normal file
157
src/features/settings/components/NotificationsForm.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Switch} from '@astryxdesign/core/Switch';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface EventNotification {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
email: boolean;
|
||||
sms: boolean;
|
||||
whatsapp: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_EVENTS: EventNotification[] = [
|
||||
{
|
||||
id: 'dailySummary',
|
||||
label: 'Daily Performance Summary',
|
||||
description: 'Daily morning dispatch with footfall, revenue and LYT redemptions.',
|
||||
email: true,
|
||||
sms: false,
|
||||
whatsapp: true,
|
||||
push: true,
|
||||
},
|
||||
{
|
||||
id: 'expiryAlerts',
|
||||
label: 'Reward Expiry Warning',
|
||||
description: 'Alert when active rewards are within 48 hours of expiration.',
|
||||
email: true,
|
||||
sms: true,
|
||||
whatsapp: true,
|
||||
push: true,
|
||||
},
|
||||
{
|
||||
id: 'staffAbsence',
|
||||
label: 'Staff Absence & Late Check-in',
|
||||
description: 'Instant alert when a scheduled shift starts without rostered staff.',
|
||||
email: true,
|
||||
sms: true,
|
||||
whatsapp: false,
|
||||
push: true,
|
||||
},
|
||||
{
|
||||
id: 'lowConversion',
|
||||
label: 'Low Conversion Drop Alert',
|
||||
description: 'Triggered if a store conversion rate drops below 15% threshold.',
|
||||
email: true,
|
||||
sms: false,
|
||||
whatsapp: true,
|
||||
push: false,
|
||||
},
|
||||
{
|
||||
id: 'settlementReceipt',
|
||||
label: 'LYT Settlement Receipts',
|
||||
description: 'Weekly payout summary and bank credit confirmations.',
|
||||
email: true,
|
||||
sms: true,
|
||||
whatsapp: true,
|
||||
push: false,
|
||||
},
|
||||
{
|
||||
id: 'securityAlerts',
|
||||
label: 'Security & New Login Alerts',
|
||||
description: 'Immediate notification on unrecognized IP or new device login.',
|
||||
email: true,
|
||||
sms: true,
|
||||
whatsapp: true,
|
||||
push: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationsForm() {
|
||||
const toast = useToast();
|
||||
const [events, setEvents] = useState<EventNotification[]>(DEFAULT_EVENTS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const toggleChannel = (
|
||||
id: string,
|
||||
channel: 'email' | 'sms' | 'whatsapp' | 'push',
|
||||
) => {
|
||||
setEvents((prev) =>
|
||||
prev.map((e) => (e.id === id ? {...e, [channel]: !e[channel]} : e)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
toast({body: 'Multi-channel notification settings saved'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Notification Preferences & Channels"
|
||||
subtitle="Configure delivery channels (Email, SMS, WhatsApp, Push) for business alerts."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save Preferences"
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
{events.map((evt, idx) => (
|
||||
<VStack key={evt.id} gap={3}>
|
||||
<HStack hAlign="between" vAlign="center" wrap="wrap" gap={3}>
|
||||
<VStack gap={0.5} width={380}>
|
||||
<Text size="sm" weight="medium">
|
||||
{evt.label}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{evt.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<HStack gap={3} vAlign="center" wrap="wrap">
|
||||
<Switch
|
||||
label="Email"
|
||||
value={evt.email}
|
||||
onChange={() => toggleChannel(evt.id, 'email')}
|
||||
/>
|
||||
<Switch
|
||||
label="SMS"
|
||||
value={evt.sms}
|
||||
onChange={() => toggleChannel(evt.id, 'sms')}
|
||||
/>
|
||||
<Switch
|
||||
label="WhatsApp"
|
||||
value={evt.whatsapp}
|
||||
onChange={() => toggleChannel(evt.id, 'whatsapp')}
|
||||
/>
|
||||
<Switch
|
||||
label="Push"
|
||||
value={evt.push}
|
||||
onChange={() => toggleChannel(evt.id, 'push')}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
{idx === events.length - 1 ? null : <Divider />}
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
154
src/features/settings/components/PreferencesForm.tsx
Normal file
154
src/features/settings/components/PreferencesForm.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {useThemeMode} from '@/shared/providers/ThemeModeProvider';
|
||||
import type {ThemeMode} from '@/shared/theme/themeMode';
|
||||
|
||||
/** Theme mode is deliberately absent — it lives in ThemeModeProvider. */
|
||||
export interface SystemPreferences {
|
||||
currency: 'INR' | 'USD' | 'EUR' | 'GBP';
|
||||
language: 'en' | 'hi' | 'kn' | 'ta';
|
||||
dateFormat: 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD';
|
||||
defaultLanding: '/dashboard' | '/stores' | '/lyts' | '/staff';
|
||||
}
|
||||
|
||||
const DEFAULT_PREFS: SystemPreferences = {
|
||||
currency: 'INR',
|
||||
language: 'en',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
defaultLanding: '/dashboard',
|
||||
};
|
||||
|
||||
export function PreferencesForm() {
|
||||
const toast = useToast();
|
||||
/**
|
||||
* Theme is the one preference that does NOT go through the save cycle.
|
||||
* It repaints the workspace the instant it changes and persists itself to
|
||||
* the theme-mode cookie, so there is nothing left for Save to commit — and
|
||||
* a mode you can see but have not "saved" would be the confusing state.
|
||||
* The rest of the form is still mock local state.
|
||||
*/
|
||||
const {mode: themeMode, setMode: setThemeMode} = useThemeMode();
|
||||
const [prefs, setPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
|
||||
const [savedPrefs, setSavedPrefs] = useState<SystemPreferences>(DEFAULT_PREFS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const set = <K extends keyof SystemPreferences>(
|
||||
key: K,
|
||||
val: SystemPreferences[K],
|
||||
) => setPrefs((p) => ({...p, [key]: val}));
|
||||
|
||||
const isDirty = JSON.stringify(prefs) !== JSON.stringify(savedPrefs);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
setSavedPrefs(prefs);
|
||||
setIsSaving(false);
|
||||
toast({body: 'Workspace preferences updated'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Workspace Display & Formatting Preferences"
|
||||
subtitle="Customize theme mode, reporting currency, language and default start screen."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => setPrefs(savedPrefs)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save Preferences"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Theme Interface Mode"
|
||||
value={themeMode}
|
||||
onChange={(v) => setThemeMode(v as ThemeMode)}
|
||||
options={[
|
||||
{label: 'Dark Mode (Monochrome Premium)', value: 'dark'},
|
||||
{label: 'Light Mode', value: 'light'},
|
||||
{label: 'System OS Preference', value: 'system'},
|
||||
]}
|
||||
/>
|
||||
<Selector
|
||||
label="Reporting Base Currency"
|
||||
value={prefs.currency}
|
||||
onChange={(v) => set('currency', v as SystemPreferences['currency'])}
|
||||
options={[
|
||||
{label: 'INR (₹ Indian Rupee)', value: 'INR'},
|
||||
{label: 'USD ($ US Dollar)', value: 'USD'},
|
||||
{label: 'EUR (€ Euro)', value: 'EUR'},
|
||||
{label: 'GBP (£ British Pound)', value: 'GBP'},
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Workspace Language"
|
||||
value={prefs.language}
|
||||
onChange={(v) => set('language', v as SystemPreferences['language'])}
|
||||
options={[
|
||||
{label: 'English (US & India)', value: 'en'},
|
||||
{label: 'Hindi (हिंदी)', value: 'hi'},
|
||||
{label: 'Kannada (ಕನ್ನಡ)', value: 'kn'},
|
||||
{label: 'Tamil (தமிழ்)', value: 'ta'},
|
||||
]}
|
||||
/>
|
||||
<Selector
|
||||
label="Date Formatting"
|
||||
value={prefs.dateFormat}
|
||||
onChange={(v) => set('dateFormat', v as SystemPreferences['dateFormat'])}
|
||||
options={[
|
||||
{label: 'DD/MM/YYYY (e.g. 05/08/2026)', value: 'DD/MM/YYYY'},
|
||||
{label: 'MM/DD/YYYY (e.g. 08/05/2026)', value: 'MM/DD/YYYY'},
|
||||
{label: 'YYYY-MM-DD (e.g. 2026-08-05)', value: 'YYYY-MM-DD'},
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<Selector
|
||||
label="Default Home Landing Module"
|
||||
value={prefs.defaultLanding}
|
||||
onChange={(v) => set('defaultLanding', v as SystemPreferences['defaultLanding'])}
|
||||
options={[
|
||||
{label: 'Dashboard Overview', value: '/dashboard'},
|
||||
{label: 'Store Locations', value: '/stores'},
|
||||
{label: 'Lyts & Rewards Catalogue', value: '/lyts'},
|
||||
{label: 'Staff Roster & Performance', value: '/staff'},
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
|
||||
165
src/features/settings/components/ProfileForm.tsx
Normal file
165
src/features/settings/components/ProfileForm.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {settingsService} from '@/features/settings/services/settingsService';
|
||||
import type {ProfileFieldErrors} from '@/features/settings/services/settingsService';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Merchant profile.
|
||||
*
|
||||
* `initialData` arrives from a Server Component that read the record
|
||||
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
|
||||
* rather than flashing empty inputs. This is the hybrid path the data layer
|
||||
* was designed for: dashboards fetch on the client because their scope
|
||||
* changes constantly, but a settings form has exactly one server-known state
|
||||
* at page load and should use it.
|
||||
*
|
||||
* Status is only ever set on submit, never at initial paint — Astryx's
|
||||
* FieldStatus breaks SSR hydration when it is server-rendered with a status
|
||||
* already applied (see AGENTS.md).
|
||||
*/
|
||||
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(initialData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ProfileFieldErrors>({});
|
||||
|
||||
const set = <K extends keyof MerchantProfile>(
|
||||
key: K,
|
||||
value: MerchantProfile[K],
|
||||
) => setForm((f) => ({...f, [key]: value}));
|
||||
|
||||
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
|
||||
|
||||
async function save() {
|
||||
// Validation and transport both live in settingsService — this component
|
||||
// decides what to render, never what a valid profile is or where it goes.
|
||||
// The raw fetch that used to sit here was the last one in the codebase.
|
||||
setIsSaving(true);
|
||||
const result = await settingsService.saveProfile(form);
|
||||
setIsSaving(false);
|
||||
|
||||
if (result.ok) {
|
||||
setErrors({});
|
||||
toast({body: 'Profile saved'});
|
||||
return;
|
||||
}
|
||||
setErrors(result.errors);
|
||||
if (result.message) {
|
||||
toast({type: 'error', body: result.message});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Business profile"
|
||||
subtitle="Shown on receipts and used for LYT settlement."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => {
|
||||
setForm(initialData);
|
||||
setErrors({});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save changes"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={save}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Business name"
|
||||
value={form.businessName}
|
||||
onChange={(v) => set('businessName', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.businessName
|
||||
? {type: 'error', message: errors.businessName}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact name"
|
||||
value={form.contactName}
|
||||
onChange={(v) => set('contactName', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => set('email', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.email ? {type: 'error', message: errors.email} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form.phone}
|
||||
onChange={(v) => set('phone', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="GSTIN"
|
||||
value={form.gstin}
|
||||
onChange={(v) => set('gstin', v.toUpperCase())}
|
||||
description="15-character GST registration number"
|
||||
status={
|
||||
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Timezone"
|
||||
value={form.timezone}
|
||||
onChange={(v) => set('timezone', v)}
|
||||
description="All reports are rendered in this zone"
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<NumberInput
|
||||
label="LYTs per ₹100 spent"
|
||||
value={form.lytsPerHundred}
|
||||
onChange={(v) => set('lytsPerHundred', v ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
|
||||
width={320}
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
221
src/features/settings/components/RoleMatrix.tsx
Normal file
221
src/features/settings/components/RoleMatrix.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface PermissionRow extends Record<string, unknown> {
|
||||
module: string;
|
||||
description: string;
|
||||
Owner: boolean;
|
||||
Manager: boolean;
|
||||
Cashier: boolean;
|
||||
Marketing: boolean;
|
||||
Support: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_MATRIX: PermissionRow[] = [
|
||||
{
|
||||
module: 'Dashboard & Analytics',
|
||||
description: 'View sales timeseries, footfall, conversion trends and KPIs',
|
||||
Owner: true,
|
||||
Manager: true,
|
||||
Cashier: false,
|
||||
Marketing: true,
|
||||
Support: false,
|
||||
},
|
||||
{
|
||||
module: 'Store Management',
|
||||
description: 'Add, edit, disable stores and change operating hours',
|
||||
Owner: true,
|
||||
Manager: true,
|
||||
Cashier: false,
|
||||
Marketing: false,
|
||||
Support: false,
|
||||
},
|
||||
{
|
||||
module: 'Staff Directory',
|
||||
description: 'Manage staff rosters, assign stores, reset passwords',
|
||||
Owner: true,
|
||||
Manager: true,
|
||||
Cashier: false,
|
||||
Marketing: false,
|
||||
Support: true,
|
||||
},
|
||||
{
|
||||
module: 'Rewards & LYTs',
|
||||
description: 'Create rewards, issue LYTs, adjust redemption rules',
|
||||
Owner: true,
|
||||
Manager: true,
|
||||
Cashier: true,
|
||||
Marketing: true,
|
||||
Support: false,
|
||||
},
|
||||
{
|
||||
module: 'Billing & Settlement',
|
||||
description: 'View invoices, change payment methods, set bank account',
|
||||
Owner: true,
|
||||
Manager: false,
|
||||
Cashier: false,
|
||||
Marketing: false,
|
||||
Support: false,
|
||||
},
|
||||
{
|
||||
module: 'API Keys & Webhooks',
|
||||
description: 'Generate developer keys and manage webhook event triggers',
|
||||
Owner: true,
|
||||
Manager: false,
|
||||
Cashier: false,
|
||||
Marketing: false,
|
||||
Support: false,
|
||||
},
|
||||
{
|
||||
module: 'Security & Audit Logs',
|
||||
description: 'Revoke active sessions, 2FA settings and login history',
|
||||
Owner: true,
|
||||
Manager: true,
|
||||
Cashier: false,
|
||||
Marketing: false,
|
||||
Support: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function RoleMatrix() {
|
||||
const toast = useToast();
|
||||
const [matrix, setMatrix] = useState<PermissionRow[]>(INITIAL_MATRIX);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const togglePermission = (
|
||||
moduleName: string,
|
||||
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
|
||||
) => {
|
||||
if (role === 'Owner') return; // Owner permissions locked
|
||||
setMatrix((prev) =>
|
||||
prev.map((item) =>
|
||||
item.module === moduleName ? {...item, [role]: !item[role]} : item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
toast({body: 'Role permission matrix updated successfully'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const renderCheck = (
|
||||
row: PermissionRow,
|
||||
role: 'Owner' | 'Manager' | 'Cashier' | 'Marketing' | 'Support',
|
||||
) => {
|
||||
const isGranted = row[role];
|
||||
return (
|
||||
<HStack hAlign="center" width="full">
|
||||
<div className="w-[108px] flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isGranted ? 'primary' : 'secondary'}
|
||||
isDisabled={role === 'Owner'}
|
||||
label={isGranted ? 'Granted' : 'No Access'}
|
||||
onClick={() => togglePermission(row.module, role)}
|
||||
className="w-full flex justify-center text-center"
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: TableColumn<PermissionRow>[] = [
|
||||
{
|
||||
key: 'module',
|
||||
header: 'Module / Feature Area',
|
||||
width: proportional(3),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.module}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.description}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'Owner',
|
||||
header: 'Owner',
|
||||
align: 'center',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => renderCheck(row, 'Owner'),
|
||||
},
|
||||
{
|
||||
key: 'Manager',
|
||||
header: 'Manager',
|
||||
align: 'center',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => renderCheck(row, 'Manager'),
|
||||
},
|
||||
{
|
||||
key: 'Cashier',
|
||||
header: 'Cashier',
|
||||
align: 'center',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => renderCheck(row, 'Cashier'),
|
||||
},
|
||||
{
|
||||
key: 'Marketing',
|
||||
header: 'Marketing',
|
||||
align: 'center',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => renderCheck(row, 'Marketing'),
|
||||
},
|
||||
{
|
||||
key: 'Support',
|
||||
header: 'Support',
|
||||
align: 'center',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => renderCheck(row, 'Support'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Role Access Control Matrix"
|
||||
subtitle="Configure granular module access levels for Owner, Manager, Cashier, Marketing and Support roles."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save Permissions"
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<HStack gap={2} wrap="wrap">
|
||||
<Badge variant="info" label="Owner (Full Root Access)" />
|
||||
<Badge variant="neutral" label="Manager (Operational Controls)" />
|
||||
<Badge variant="neutral" label="Cashier (POS & Rewards)" />
|
||||
<Badge variant="neutral" label="Marketing (Campaigns & Analytics)" />
|
||||
<Badge variant="neutral" label="Support (Staff Roster View)" />
|
||||
</HStack>
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={matrix}
|
||||
primaryKey="module"
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
320
src/features/settings/components/SecurityManager.tsx
Normal file
320
src/features/settings/components/SecurityManager.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Switch} from '@astryxdesign/core/Switch';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {securityRepository} from '@/features/settings/repositories/securityRepository';
|
||||
import type {DeviceSession} from '@/features/settings/services/mapSession';
|
||||
|
||||
export interface ActiveSessionItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
ip: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface LoginAuditItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
user: string;
|
||||
ip: string;
|
||||
device: string;
|
||||
status: 'success' | 'failed';
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const AUDIT_LOGS: LoginAuditItem[] = [
|
||||
{
|
||||
id: 'aud-1',
|
||||
user: 'aravind@nearle.in',
|
||||
ip: '103.15.24.81',
|
||||
device: 'macOS Chrome',
|
||||
status: 'success',
|
||||
timestamp: 'Today, 14:02',
|
||||
},
|
||||
{
|
||||
id: 'aud-2',
|
||||
user: 'vikram@nearle.in',
|
||||
ip: '106.51.72.19',
|
||||
device: 'Windows Chrome',
|
||||
status: 'success',
|
||||
timestamp: 'Today, 11:15',
|
||||
},
|
||||
{
|
||||
id: 'aud-3',
|
||||
user: 'aravind@nearle.in',
|
||||
ip: '185.220.101.5',
|
||||
device: 'Unknown Linux Device',
|
||||
status: 'failed',
|
||||
timestamp: 'Yesterday, 22:30',
|
||||
},
|
||||
];
|
||||
|
||||
export function SecurityManager() {
|
||||
const toast = useToast();
|
||||
const [twoFactor, setTwoFactor] = useState(true);
|
||||
/**
|
||||
* Real devices, from GET /api/auth/sessions.
|
||||
*
|
||||
* This was three invented rows in useState, with a `location` and an `ip` the
|
||||
* platform does not return at all — a merchant checking for an unfamiliar
|
||||
* sign-in was reading fiction. Revoking one spliced the local array and the
|
||||
* row came back on refresh.
|
||||
*
|
||||
* Everything else in this file — the 2FA switch, the password form and the
|
||||
* audit trail — is still local-only. Those have no endpoint on the platform
|
||||
* and are deliberately left as they were rather than deleted.
|
||||
*/
|
||||
const sessions = useResource(securityRepository.sessions());
|
||||
const [currentPass, setCurrentPass] = useState('');
|
||||
const [newPass, setNewPass] = useState('');
|
||||
const [confirmPass, setConfirmPass] = useState('');
|
||||
const [isChangingPass, setIsChangingPass] = useState(false);
|
||||
|
||||
const handleChangePassword = () => {
|
||||
if (!currentPass || !newPass) {
|
||||
toast({type: 'error', body: 'Please fill in all password fields'});
|
||||
return;
|
||||
}
|
||||
if (newPass !== confirmPass) {
|
||||
toast({type: 'error', body: 'New passwords do not match'});
|
||||
return;
|
||||
}
|
||||
setIsChangingPass(true);
|
||||
setTimeout(() => {
|
||||
setIsChangingPass(false);
|
||||
setCurrentPass('');
|
||||
setNewPass('');
|
||||
setConfirmPass('');
|
||||
toast({body: 'Password updated successfully'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (id: string) => {
|
||||
const res = await securityRepository.revoke(id);
|
||||
if (!res.ok) {
|
||||
toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
|
||||
return;
|
||||
}
|
||||
// Re-read rather than splice: the platform decides what is still live, and
|
||||
// a locally filtered array is a guess that survives until the next refresh.
|
||||
sessions.refetch();
|
||||
toast({body: 'That device has been signed out.'});
|
||||
};
|
||||
|
||||
const handleRevokeAllOther = async () => {
|
||||
const res = await securityRepository.revokeOthers();
|
||||
if (!res.ok) {
|
||||
toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
|
||||
return;
|
||||
}
|
||||
sessions.refetch();
|
||||
toast({body: 'Every other device has been signed out.'});
|
||||
};
|
||||
|
||||
const sessionColumns: TableColumn<DeviceSession & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Device',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{row.device}
|
||||
</Text>
|
||||
{row.isCurrent ? (
|
||||
<Badge variant="success" label="This browser" />
|
||||
) : null}
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUsedAt',
|
||||
header: 'Last used',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{new Date(row.lastUsedAt).toLocaleString()}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Action',
|
||||
align: 'center',
|
||||
width: pixel(120),
|
||||
// The current session CAN be revoked upstream, but doing it from this
|
||||
// table signs you out mid-page with no warning. Signing out is what the
|
||||
// account menu is for.
|
||||
renderCell: (row) =>
|
||||
row.isCurrent ? null : (
|
||||
<HStack hAlign="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Sign out"
|
||||
onClick={() => void handleRevokeSession(row.id)}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const auditColumns: TableColumn<LoginAuditItem>[] = [
|
||||
{
|
||||
key: 'user',
|
||||
header: 'Account User',
|
||||
width: proportional(1.5),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.user}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Device & IP',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm">{row.device}</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.ip}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Result',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status === 'success' ? 'success' : 'error'}
|
||||
label={row.status === 'success' ? 'Success' : 'Failed'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.timestamp}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Two-Factor Authentication (2FA)"
|
||||
subtitle="Require an authenticator app code (TOTP) or SMS OTP during sign-in."
|
||||
actions={<Badge variant={twoFactor ? 'success' : 'warning'} label={twoFactor ? '2FA Enabled' : '2FA Disabled'} />}
|
||||
>
|
||||
<VStack gap={3}>
|
||||
<Switch
|
||||
label="Enable Two-Factor Authentication"
|
||||
description="Protect your merchant account with an extra verification step on new devices."
|
||||
value={twoFactor}
|
||||
onChange={(v) => {
|
||||
setTwoFactor(v);
|
||||
toast({body: `2FA ${v ? 'enabled' : 'disabled'}`});
|
||||
}}
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Change Password"
|
||||
subtitle="Ensure your password is at least 12 characters long with mixed case and numbers."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Update Password"
|
||||
isLoading={isChangingPass}
|
||||
onClick={handleChangePassword}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<TextInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPass}
|
||||
onChange={setCurrentPass}
|
||||
/>
|
||||
<HStack gap={4} wrap="wrap">
|
||||
<TextInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPass}
|
||||
onChange={setNewPass}
|
||||
/>
|
||||
<TextInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPass}
|
||||
onChange={setConfirmPass}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<PanelCard
|
||||
title="Active sessions"
|
||||
subtitle="Every device signed in as you."
|
||||
resource={sessions}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="security"
|
||||
title="No other devices"
|
||||
description="Only this browser is signed in."
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Sign out everywhere else"
|
||||
onClick={() => void handleRevokeAllOther()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable
|
||||
columns={sessionColumns}
|
||||
data={rows as (DeviceSession & Record<string, unknown>)[]}
|
||||
idKey="id"
|
||||
primaryKey="device"
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
|
||||
<ResponsiveTable
|
||||
columns={auditColumns}
|
||||
data={AUDIT_LOGS}
|
||||
primaryKey="user"
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
74
src/features/settings/components/SettingsPage.tsx
Normal file
74
src/features/settings/components/SettingsPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
|
||||
/**
|
||||
* The container every Settings screen sits in.
|
||||
*
|
||||
* WHY THIS EXISTS
|
||||
* The Settings layout nests its own `Layout` inside the workspace shell, and
|
||||
* that nested `LayoutContent` was set to `padding={0}` so the sub-nav panel
|
||||
* could sit flush. The side effect was that it also cancelled the workspace's
|
||||
* own 20px gutter, so every Settings page rendered hard against the sub-nav on
|
||||
* the left and Loyaly AI divider on the right, with the title flush to the
|
||||
* top of the viewport. Eleven pages each fixing that themselves is how the
|
||||
* spacing drifts, so it is fixed once, here.
|
||||
*
|
||||
* THE SPACING CONTRACT
|
||||
* Horizontal 32px (spacing-8) — clears the sub-nav and the Loyaly AI rail
|
||||
* Top 32px (spacing-8) — the title never touches the header
|
||||
* Bottom 48px (pb-12) — the last card is never flush to the fold
|
||||
* Header → body 32px (spacing-8) — title block reads as its own layer
|
||||
* Between blocks 24px (spacing-6) — cards, tables and toolbars
|
||||
*
|
||||
* The bottom padding goes through the Tailwind bridge rather than a prop
|
||||
* because Astryx's `SpacingStep` union stops at 10 (40px) and there is no
|
||||
* `paddingBlockEnd` on Stack. `pb-12` resolves to `--spacing-12` (48px), so it
|
||||
* is still a token — no raw pixel value enters the codebase. It lands in the
|
||||
* `utilities` layer, which the cascade puts after `astryx-base`, so it wins
|
||||
* over the `paddingBlock` set below without `!important`.
|
||||
*
|
||||
* Anything that needs a different rhythm should take a prop here rather than
|
||||
* override locally — that is the whole point of a single container.
|
||||
*/
|
||||
export function SettingsPage({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
toolbar,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** Page-level verbs — Save, Add store, Rotate key. */
|
||||
actions?: React.ReactNode;
|
||||
/** Filters or segmented controls that scope this page's content. */
|
||||
toolbar?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<VStack
|
||||
as="section"
|
||||
paddingInline={8}
|
||||
paddingBlock={8}
|
||||
gap={8}
|
||||
className="pb-12"
|
||||
width="100%"
|
||||
>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={description}
|
||||
actions={actions}
|
||||
/>
|
||||
|
||||
{/* 24px between the body's own blocks — tighter than the 32px that
|
||||
separates the header from the body, so the page reads as
|
||||
"title, then a group of related panels" rather than a flat list. */}
|
||||
<VStack gap={6} width="100%">
|
||||
{toolbar}
|
||||
{children}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
221
src/features/settings/components/StoreManagement.tsx
Normal file
221
src/features/settings/components/StoreManagement.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {hasRole} from '@/features/auth/services/roles';
|
||||
import {useSites} from '@/features/stores/hooks/useSites';
|
||||
import {OpenShopDialog} from '@/features/stores/components/OpenShopDialog';
|
||||
import {RenameShopDialog} from '@/features/stores/components/RenameShopDialog';
|
||||
import {RemoveShopDialog} from '@/features/stores/components/RemoveShopDialog';
|
||||
import type {Site} from '@/features/stores/types/site';
|
||||
|
||||
type SiteRow = Site & Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* The company's shops, from the platform — open, rename, remove.
|
||||
*
|
||||
* This screen used to be a hardcoded list of five Bengaluru shops held in
|
||||
* local state: adding one "worked" until the page reloaded. It now reads
|
||||
* GET /api/sites and writes through POST / PATCH / DELETE on the same
|
||||
* resource, and every write re-reads rather than splicing local state.
|
||||
*
|
||||
* Who is offered what mirrors the platform's own rules — opening and removing
|
||||
* a shop is the owner's, renaming is a manager's too — but the platform is
|
||||
* what enforces them; hiding a button is courtesy, not security.
|
||||
*
|
||||
* Cameras and the shop PC are set up per shop on the Store page, not here:
|
||||
* this is the directory, that is the equipment.
|
||||
*/
|
||||
export function StoreManagement() {
|
||||
const toast = useToast();
|
||||
const sites = useSites();
|
||||
const {refreshStores} = useWorkspace();
|
||||
const {user} = useSession();
|
||||
const isOwner = hasRole(user?.role, 'owner');
|
||||
const isManager = hasRole(user?.role, 'manager');
|
||||
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [renaming, setRenaming] = useState<Site | null>(null);
|
||||
const [removing, setRemoving] = useState<Site | null>(null);
|
||||
|
||||
/** The settings table and the global switcher each hold a copy; refresh both. */
|
||||
function reload() {
|
||||
sites.refetch();
|
||||
refreshStores();
|
||||
}
|
||||
|
||||
const columns: TableColumn<SiteRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Shop',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary" className="font-mono">
|
||||
{row.id}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'timezone',
|
||||
header: 'Timezone',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.timezone || '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'isOnline',
|
||||
header: 'Shop PC',
|
||||
width: pixel(150),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.isOnline === null ? 'neutral' : row.isOnline ? 'success' : 'error'}
|
||||
label={row.isOnline === null ? 'Not reported' : row.isOnline ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.isOnline === null ? 'Not reported' : row.isOnline ? 'Online' : 'Offline'}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'camerasUp',
|
||||
header: 'Cameras',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.camerasUp === null || row.camerasTotal === null
|
||||
? '—'
|
||||
: `${row.camerasUp}/${row.camerasTotal} up`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'center',
|
||||
width: pixel(140),
|
||||
renderCell: (row) =>
|
||||
isManager ? (
|
||||
<HStack hAlign="center">
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: 'Manage',
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}}
|
||||
menuWidth={180}
|
||||
items={[
|
||||
{label: 'Rename', onClick: () => setRenaming(row)},
|
||||
...(isOwner
|
||||
? [
|
||||
{type: 'divider' as const},
|
||||
{label: 'Remove shop', onClick: () => setRemoving(row)},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</HStack>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PanelCard
|
||||
title="Shops"
|
||||
subtitle="Every shop in your company. Cameras and the shop PC are set up on the Store page."
|
||||
actions={
|
||||
isOwner ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Open a shop"
|
||||
onClick={() => setOpening(true)}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
resource={sites}
|
||||
loading={<SkeletonRows count={4} height={52} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No shops yet"
|
||||
description={
|
||||
isOwner
|
||||
? 'Open your first shop, then add its cameras on the Store page.'
|
||||
: 'Your company has no shops yet. The owner opens them.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={rows as SiteRow[]}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{opening ? (
|
||||
<OpenShopDialog
|
||||
onClose={() => setOpening(false)}
|
||||
onOpened={() => {
|
||||
setOpening(false);
|
||||
toast({body: 'Shop opened. Add its cameras on the Store page.'});
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{renaming ? (
|
||||
<RenameShopDialog
|
||||
site={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={() => {
|
||||
setRenaming(null);
|
||||
toast({body: 'Shop updated'});
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{removing ? (
|
||||
<RemoveShopDialog
|
||||
site={removing}
|
||||
onClose={() => setRemoving(null)}
|
||||
onRemoved={() => {
|
||||
setRemoving(null);
|
||||
toast({body: 'Shop removed'});
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
273
src/features/settings/components/TeamManagement.tsx
Normal file
273
src/features/settings/components/TeamManagement.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {useInvitations, useTeam} from '@/features/team/hooks/useTeam';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import {AddMemberDialog} from '@/features/team/components/AddMemberDialog';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
|
||||
/**
|
||||
* Who has access to this console.
|
||||
*
|
||||
* ── What this replaced ───────────────────────────────────────────────────
|
||||
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
|
||||
* held in `useState`, with store names (`Indiranagar Flagship`,
|
||||
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
|
||||
* `Marketing`, `Support`) that do not exist upstream, and four controls that
|
||||
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
|
||||
* local state and were lost on refresh. A merchant could not tell any of it
|
||||
* from the real thing.
|
||||
*
|
||||
* ── Why the actions are here now ─────────────────────────────────────────
|
||||
* This was read-only, because `PATCH /api/team/{id}`, the invitation endpoints
|
||||
* and `POST /api/team/members` all answered 404 on the deployed platform when
|
||||
* it was built. They do not any more, so the screen does what it always
|
||||
* described: lists who has access and changes it.
|
||||
*
|
||||
* Deactivating revokes every session that person holds IMMEDIATELY — it is not
|
||||
* a flag that takes effect at their next sign-in. The platform also refuses to
|
||||
* remove the last active owner, answering `409 last_owner`; that message is
|
||||
* surfaced verbatim rather than flattened into "something went wrong", because
|
||||
* it names a rule the merchant can act on.
|
||||
*/
|
||||
|
||||
interface TeamRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read off the payload, never inferred. The platform sends a boolean; there is
|
||||
* no "invited" or "suspended" state on this endpoint, so the screen does not
|
||||
* invent one.
|
||||
*/
|
||||
function toRow(m: TeamMember): TeamRow {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
status: m.active ? 'Active' : 'Inactive',
|
||||
// "Never" is a fact worth showing: an account created and never used is
|
||||
// exactly the row a manager wants to notice.
|
||||
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<TeamRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Member',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Access',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
export function TeamManagement() {
|
||||
const team = useTeam();
|
||||
const invitations = useInvitations();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reset, setReset] = useState<{label: string; value: string} | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
function refresh() {
|
||||
team.refetch();
|
||||
invitations.refetch();
|
||||
}
|
||||
|
||||
async function setActive(id: string, active: boolean) {
|
||||
setError(null);
|
||||
const res = await teamRepository.update(id, {active});
|
||||
// `last_owner` is the one failure worth repeating word for word: it names a
|
||||
// rule rather than a fault, and the merchant can act on it.
|
||||
if (!res.ok) setError(res.message ?? 'Could not change that account.');
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function resetPassword(id: string, email: string) {
|
||||
setError(null);
|
||||
const res = await teamRepository.resetPassword(id);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'Could not reset that password.');
|
||||
return;
|
||||
}
|
||||
setReset({label: `New password for ${email}`, value: res.data.password});
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Console access"
|
||||
subtitle="Everybody who can sign in to this company's dashboard."
|
||||
resource={team}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No team members yet"
|
||||
description="People appear here once they accept an invitation."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(members) => (
|
||||
<VStack gap={4}>
|
||||
<ResponsiveTable
|
||||
data={members.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['role', 'status']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
{reset ? (
|
||||
<SecretOnce value={reset.value} label={reset.label} />
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<Text size="xsm" color="secondary">
|
||||
An invitation lets somebody choose their own password; a created
|
||||
login shows one once. Deactivating signs that person out at once.
|
||||
</Text>
|
||||
<Button size="sm" onClick={() => setAdding(true)} label="Add someone" />
|
||||
</HStack>
|
||||
|
||||
{members.some((m) => !m.active) ? (
|
||||
<VStack gap={2}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Deactivated
|
||||
</Text>
|
||||
{members
|
||||
.filter((m) => !m.active)
|
||||
.map((m) => (
|
||||
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm">{m.name}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void setActive(m.id, true)}
|
||||
label="Restore access"
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<VStack gap={2}>
|
||||
{members
|
||||
.filter((m) => m.active)
|
||||
.map((m) => (
|
||||
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" color="secondary">
|
||||
{m.email}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void resetPassword(m.id, m.email)}
|
||||
label="Reset password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void setActive(m.id, false)}
|
||||
label="Deactivate"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
{invitations.data && invitations.data.length > 0 ? (
|
||||
<VStack gap={2}>
|
||||
<Text size="xsm" color="secondary">
|
||||
Invited, not yet joined
|
||||
</Text>
|
||||
{invitations.data.map((inv) => (
|
||||
<HStack key={inv.id} gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" color="secondary">
|
||||
{inv.email} · {inv.role}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void teamRepository
|
||||
.revokeInvitation(inv.id)
|
||||
.then(() => invitations.refetch());
|
||||
}}
|
||||
label="Withdraw"
|
||||
/>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
{adding ? (
|
||||
<AddMemberDialog
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
78
src/features/settings/config/settingsNav.ts
Normal file
78
src/features/settings/config/settingsNav.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
|
||||
export interface SettingsSection {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: IconType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings sub-navigation.
|
||||
*
|
||||
* Same shape as the primary nav-config for the same reason: adding a section
|
||||
* should be a one-line push here plus a route folder, with nothing in the
|
||||
* layout changing.
|
||||
*/
|
||||
export const SETTINGS_NAV: SettingsSection[] = [
|
||||
{
|
||||
label: 'Business',
|
||||
href: '/settings',
|
||||
icon: ICONS.business,
|
||||
description: 'Company details, tax IDs, registered address and support.',
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
href: '/settings/profile',
|
||||
icon: ICONS.profile,
|
||||
description: 'Merchant contact details, timezone and personal info.',
|
||||
},
|
||||
{
|
||||
label: 'Team & Staff',
|
||||
href: '/settings/team',
|
||||
icon: ICONS.staff,
|
||||
description: 'Staff directory, store assignments, roles and status.',
|
||||
},
|
||||
{
|
||||
label: 'Stores',
|
||||
href: '/settings/stores',
|
||||
icon: ICONS.stores,
|
||||
description: 'Manage store locations, operating hours and managers.',
|
||||
},
|
||||
{
|
||||
label: 'Roles & Permissions',
|
||||
href: '/settings/roles',
|
||||
icon: ICONS.roles,
|
||||
description: 'Access levels, module permissions and role matrices.',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
href: '/settings/notifications',
|
||||
icon: ICONS.notifications,
|
||||
description: 'Email, SMS, WhatsApp and Push alert channels.',
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
href: '/settings/billing',
|
||||
icon: ICONS.revenue,
|
||||
description: 'Subscription plans, payment methods and LYT settlement.',
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
href: '/settings/security',
|
||||
icon: ICONS.security,
|
||||
description: '2FA authentication, active sessions and audit history.',
|
||||
},
|
||||
{
|
||||
label: 'Preferences',
|
||||
href: '/settings/preferences',
|
||||
icon: ICONS.preferences,
|
||||
description: 'System theme, default currency, language and formats.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Exact match only — /settings must not stay active on /settings/billing. */
|
||||
export function isSettingsActive(pathname: string, href: string): boolean {
|
||||
return pathname === href;
|
||||
}
|
||||
16
src/features/settings/repositories/securityRepository.ts
Normal file
16
src/features/settings/repositories/securityRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {deleteJson, postJson, type Endpoint} from '@/shared/services/httpClient';
|
||||
import type {DeviceSession} from '@/features/settings/services/mapSession';
|
||||
|
||||
/** Signed-in devices, and the two ways to end them. */
|
||||
export const securityRepository = {
|
||||
sessions: (): Endpoint<DeviceSession[]> => ({
|
||||
path: '/api/auth/sessions',
|
||||
params: {},
|
||||
}),
|
||||
|
||||
revoke: (id: string) =>
|
||||
deleteJson<null>(`/api/auth/sessions/${encodeURIComponent(id)}`),
|
||||
|
||||
/** Keeps this browser signed in — see the route. */
|
||||
revokeOthers: () => postJson<null>('/api/auth/sessions/revoke-others', {}),
|
||||
};
|
||||
16
src/features/settings/repositories/settingsRepository.ts
Normal file
16
src/features/settings/repositories/settingsRepository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import {getJson, patchJson} from '@/shared/services/httpClient';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Settings endpoints. See dashboardRepository for the layer's contract.
|
||||
*
|
||||
* Imperative rather than Endpoint-shaped: a settings record has one state at
|
||||
* page load and changes only when the user saves it, so there is nothing for
|
||||
* useResource's scope-keyed refetching to do.
|
||||
*/
|
||||
export const settingsRepository = {
|
||||
profile: () => getJson<MerchantProfile>('/api/settings/profile'),
|
||||
|
||||
updateProfile: (patch: Partial<MerchantProfile>) =>
|
||||
patchJson<MerchantProfile>('/api/settings/profile', patch),
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'server-only';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* The merchant profile.
|
||||
*
|
||||
* There is no business-profile resource in the platform contract — GET
|
||||
* /api/auth/me returns the signed-in USER (name, email, role, tenant), not the
|
||||
* company's GSTIN, timezone, currency or LYT accrual rate. The fixture that
|
||||
* used to answer this has been removed, so this returns null and the form
|
||||
* renders its unavailable state.
|
||||
*
|
||||
* When the resource ships, this function is the only thing that changes.
|
||||
*
|
||||
* Required: GET/PATCH /api/settings/profile — business name, GSTIN, timezone,
|
||||
* currency, notification preferences.
|
||||
*/
|
||||
export const settingsServerRepository = {
|
||||
async getProfile(): Promise<MerchantProfile | null> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
25
src/features/settings/services/mapSession.ts
Normal file
25
src/features/settings/services/mapSession.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {ApiDeviceSession} from '@/services/api/types';
|
||||
|
||||
/** A signed-in device, as the settings screen consumes it. */
|
||||
export interface DeviceSession {
|
||||
id: string;
|
||||
device: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
expiresAt: string;
|
||||
/** The browser reading this page. Warn before signing it out. */
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export function toDeviceSession(s: ApiDeviceSession): DeviceSession {
|
||||
return {
|
||||
id: s.id,
|
||||
// The platform records whatever the client sent at sign-in. An empty label
|
||||
// is possible, and "Unknown device" is more honest than a blank row.
|
||||
device: s.device || 'Unknown device',
|
||||
createdAt: s.created_at,
|
||||
lastUsedAt: s.last_used_at,
|
||||
expiresAt: s.expires_at,
|
||||
isCurrent: s.current,
|
||||
};
|
||||
}
|
||||
55
src/features/settings/services/settingsService.ts
Normal file
55
src/features/settings/services/settingsService.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {settingsRepository} from '@/features/settings/repositories/settingsRepository';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Domain rules for the settings module.
|
||||
*
|
||||
* The validation lives here rather than in the form for the same reason the
|
||||
* login rules live in authService: a rule that only exists inside a component
|
||||
* cannot be reused by a second screen, cannot be unit-tested without mounting
|
||||
* React, and quietly disappears the day the screen is redesigned.
|
||||
*/
|
||||
|
||||
export type ProfileFieldErrors = Partial<Record<keyof MerchantProfile, string>>;
|
||||
|
||||
export function validateProfile(profile: MerchantProfile): ProfileFieldErrors {
|
||||
const errors: ProfileFieldErrors = {};
|
||||
if (!profile.businessName.trim()) {
|
||||
errors.businessName = 'Business name is required';
|
||||
}
|
||||
if (!/^\S+@\S+\.\S+$/.test(profile.email)) {
|
||||
errors.email = 'Enter a valid email address';
|
||||
}
|
||||
// Optional, but a partial GSTIN is worse than none — it will fail at
|
||||
// settlement time rather than here.
|
||||
if (profile.gstin && profile.gstin.length !== 15) {
|
||||
errors.gstin = 'GSTIN is 15 characters';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export const settingsService = {
|
||||
validateProfile,
|
||||
|
||||
/**
|
||||
* Validate, then save. Returns the field errors rather than throwing —
|
||||
* invalid input is an expected outcome of a form submission, not an
|
||||
* exception, and the caller has to render them either way.
|
||||
*/
|
||||
async saveProfile(
|
||||
profile: MerchantProfile,
|
||||
): Promise<{ok: true} | {ok: false; errors: ProfileFieldErrors; message?: string}> {
|
||||
const errors = validateProfile(profile);
|
||||
if (Object.keys(errors).length > 0) return {ok: false, errors};
|
||||
|
||||
const res = await settingsRepository.updateProfile(profile);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: {},
|
||||
message: res.message ?? 'Could not save profile',
|
||||
};
|
||||
}
|
||||
return {ok: true};
|
||||
},
|
||||
};
|
||||
26
src/features/settings/types/settings.ts
Normal file
26
src/features/settings/types/settings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Merchant settings contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
export interface MerchantProfile {
|
||||
businessName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
gstin: string;
|
||||
/** IANA zone — all reporting timestamps are rendered in it. */
|
||||
timezone: string;
|
||||
currency: 'INR';
|
||||
/** Default LYTs granted per ₹100 spent. */
|
||||
lytsPerHundred: number;
|
||||
notifications: {
|
||||
expiryAlerts: boolean;
|
||||
dailySummary: boolean;
|
||||
staffAbsence: boolean;
|
||||
lowConversion: boolean;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user