diff --git a/src/app/payroll/ConfirmDialog.tsx b/src/app/payroll/ConfirmDialog.tsx new file mode 100644 index 0000000..8cf4c51 --- /dev/null +++ b/src/app/payroll/ConfirmDialog.tsx @@ -0,0 +1,42 @@ +'use client'; +import styles from './payroll.module.css'; + +interface Props { + total: number; + token: string; + recipientCount: number; + onConfirm: () => void; + onCancel: () => void; + submitting: boolean; +} + +export function ConfirmDialog({ total, token, recipientCount, onConfirm, onCancel, submitting }: Props) { + return ( +
+
+

Confirm Batch Payment

+

+ You are about to send {total.toFixed(2)} {token === 'native' ? 'XLM' : 'USDC'} to{' '} + {recipientCount} recipient{recipientCount !== 1 ? 's' : ''}. +

+

+ This action cannot be undone. Please confirm in your Freighter wallet. +

+
+ + +
+
+
+ ); +} diff --git a/src/app/payroll/CsvPreview.tsx b/src/app/payroll/CsvPreview.tsx new file mode 100644 index 0000000..37e3747 --- /dev/null +++ b/src/app/payroll/CsvPreview.tsx @@ -0,0 +1,78 @@ +'use client'; +import type { Recipient } from './types'; +import { ADDRESS_RE } from './types'; +import styles from './payroll.module.css'; + +interface Props { + text: string; + mode: 'uniform' | 'custom'; + onAdd: (recipients: Recipient[]) => void; + onCancel: () => void; +} + +function parsePreview(text: string, mode: 'uniform' | 'custom'): { valid: Recipient[]; invalid: string[] } { + const lines = text.split('\n').filter(l => l.trim()); + const valid: Recipient[] = []; + const invalid: string[] = []; + + for (const line of lines) { + const parts = line.split(',').map(p => p.trim()); + const addr = parts[0]; + if (!ADDRESS_RE.test(addr)) { + if (addr) invalid.push(addr); + continue; + } + const amount = mode === 'custom' ? (parts[1] ?? '') : ''; + valid.push({ address: addr, amount }); + } + + return { valid, invalid }; +} + +export function CsvPreview({ text, mode, onAdd, onCancel }: Props) { + const { valid, invalid } = parsePreview(text, mode); + + return ( +
+

+ Found {valid.length} valid address{valid.length !== 1 ? 'es' : ''} + {invalid.length > 0 && ( + <> and {invalid.length} invalid + )} +

+ + {valid.length > 0 && ( +
+ {valid.map(r => ( +
+ {r.address}{mode === 'custom' && r.amount ? ` — ${r.amount}` : ''} +
+ ))} +
+ )} + + {invalid.length > 0 && ( +
+

Skipped:

+ {invalid.map(addr => ( +
+ {addr} +
+ ))} +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/app/payroll/DetailsStep.tsx b/src/app/payroll/DetailsStep.tsx new file mode 100644 index 0000000..ad876b9 --- /dev/null +++ b/src/app/payroll/DetailsStep.tsx @@ -0,0 +1,66 @@ +'use client'; +import type { PayrollMode } from './types'; +import styles from './payroll.module.css'; + +interface Props { + mode: PayrollMode; + token: string; + ratePerDay: string; + startDate: string; + stopDate: string; + errors: Partial>; + onChange: (field: string, value: string) => void; +} + +export function DetailsStep({ mode, token, ratePerDay, startDate, stopDate, errors, onChange }: Props) { + return ( +
+ + + {mode === 'uniform' && ( + + )} + +
+ + +
+
+ ); +} diff --git a/src/app/payroll/ErrorSummary.tsx b/src/app/payroll/ErrorSummary.tsx new file mode 100644 index 0000000..81950cf --- /dev/null +++ b/src/app/payroll/ErrorSummary.tsx @@ -0,0 +1,26 @@ +'use client'; +import styles from './payroll.module.css'; + +interface Props { + errors: string[]; +} + +export function ErrorSummary({ errors }: Props) { + if (errors.length === 0) return null; + + return ( +
+

+ Please fix {errors.length} issue{errors.length !== 1 ? 's' : ''}: +

+ {errors.map((err, i) => ( +

+ {err} +

+ ))} +
+ ); +} diff --git a/src/app/payroll/ModeStep.tsx b/src/app/payroll/ModeStep.tsx new file mode 100644 index 0000000..a89a40f --- /dev/null +++ b/src/app/payroll/ModeStep.tsx @@ -0,0 +1,33 @@ +'use client'; +import type { PayrollMode } from './types'; +import styles from './payroll.module.css'; + +interface Props { + selected: PayrollMode | null; + onSelect: (mode: PayrollMode) => void; +} + +export function ModeStep({ selected, onSelect }: Props) { + return ( +
+
+ + +
+
+ ); +} diff --git a/src/app/payroll/RecipientBadge.tsx b/src/app/payroll/RecipientBadge.tsx new file mode 100644 index 0000000..dd7e1f0 --- /dev/null +++ b/src/app/payroll/RecipientBadge.tsx @@ -0,0 +1,18 @@ +interface Props { + count: number; +} + +export function RecipientBadge({ count }: Props) { + if (count === 0) return null; + + return ( + + {count} + + ); +} diff --git a/src/app/payroll/RecipientsStep.tsx b/src/app/payroll/RecipientsStep.tsx new file mode 100644 index 0000000..d7b3fc9 --- /dev/null +++ b/src/app/payroll/RecipientsStep.tsx @@ -0,0 +1,138 @@ +'use client'; +import { useState, useRef } from 'react'; +import type { Recipient, PayrollMode } from './types'; +import { ADDRESS_RE, parseCsvAddresses, parseCsvFile } from './types'; +import styles from './payroll.module.css'; + +type InputTab = 'manual' | 'paste' | 'upload'; + +interface Props { + mode: PayrollMode; + recipients: Recipient[]; + onChange: (recipients: Recipient[]) => void; +} + +export function RecipientsStep({ mode, recipients, onChange }: Props) { + const [tab, setTab] = useState('manual'); + const [manualAddr, setManualAddr] = useState(''); + const [pasteText, setPasteText] = useState(''); + const [pasteError, setPasteError] = useState(''); + const fileRef = useRef(null); + + function addManual() { + if (!ADDRESS_RE.test(manualAddr)) return; + if (recipients.some(r => r.address === manualAddr)) return; + onChange([...recipients, { address: manualAddr, amount: '' }]); + setManualAddr(''); + } + + function handlePaste() { + const addrs = parseCsvAddresses(pasteText); + if (addrs.length === 0) { + setPasteError('No valid Stellar addresses found'); + return; + } + setPasteError(''); + const existing = new Set(recipients.map(r => r.address)); + const newRecipients = addrs + .filter(a => !existing.has(a)) + .map(address => ({ address, amount: '' })); + onChange([...recipients, ...newRecipients]); + setPasteText(''); + } + + function handleFileUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const content = reader.result as string; + const parsed = parseCsvFile(content); + if (parsed.length === 0) return; + const existing = new Set(recipients.map(r => r.address)); + const newRecipients = parsed.filter(r => !existing.has(r.address)); + onChange([...recipients, ...newRecipients]); + }; + reader.readAsText(file); + if (fileRef.current) fileRef.current.value = ''; + } + + function removeRecipient(addr: string) { + onChange(recipients.filter(r => r.address !== addr)); + } + + function updateAmount(addr: string, amount: string) { + onChange(recipients.map(r => r.address === addr ? { ...r, amount } : r)); + } + + return ( +
+
+ + + +
+ + {tab === 'manual' && ( +
+
+ setManualAddr(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addManual(); } }} + /> + +
+
+ )} + + {tab === 'paste' && ( +
+