build: da8aa2fb-9871-4e3e-a583-70eda77c4d68
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
IonContent,
|
||||
IonPage,
|
||||
IonHeader,
|
||||
IonToolbar,
|
||||
IonTitle,
|
||||
IonButtons,
|
||||
IonButton,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonTextarea,
|
||||
IonSpinner,
|
||||
IonLabel,
|
||||
IonSelect,
|
||||
IonSelectOption,
|
||||
} from '@ionic/react';
|
||||
import { chevronBackOutline } from 'ionicons/icons';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { supabase } from '../supabase';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getTodayISO } from '../utils/dates';
|
||||
import MoneyInput from '../components/MoneyInput';
|
||||
import { parseMoney } from '../utils/money';
|
||||
|
||||
interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const CreditFormPage: React.FC = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation<{ customerId?: string }>();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<'existing' | 'new'>('existing');
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
||||
location.state?.customerId || ''
|
||||
);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [creditDate, setCreditDate] = useState(getTodayISO());
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadingCustomers, setLoadingCustomers] = useState(true);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCustomers = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('credit_customers')
|
||||
.select('id, name')
|
||||
.eq('user_id', user.id)
|
||||
.order('name');
|
||||
|
||||
if (error) throw error;
|
||||
setCustomers(data || []);
|
||||
if (!data || data.length === 0) {
|
||||
setMode('new');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching customers:', err);
|
||||
} finally {
|
||||
setLoadingCustomers(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCustomers();
|
||||
}, [user]);
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
const parsedAmount = parseMoney(amount);
|
||||
|
||||
if (mode === 'existing' && !selectedCustomerId) {
|
||||
newErrors.customer = 'Please select a customer';
|
||||
}
|
||||
if (mode === 'new' && !newName.trim()) {
|
||||
newErrors.name = 'Customer name is required';
|
||||
}
|
||||
if (parsedAmount <= 0) {
|
||||
newErrors.amount = 'Amount must be greater than 0';
|
||||
}
|
||||
if (!creditDate) {
|
||||
newErrors.date = 'Date is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validate() || !user) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
let customerId = selectedCustomerId;
|
||||
|
||||
if (mode === 'new') {
|
||||
const { data: newCustomer, error: customerError } = await supabase
|
||||
.from('credit_customers')
|
||||
.insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
name: newName.trim(),
|
||||
phone: phone.trim() || null,
|
||||
},
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (customerError) throw customerError;
|
||||
customerId = newCustomer.id;
|
||||
}
|
||||
|
||||
const { error: creditError } = await supabase.from('credits').insert([
|
||||
{
|
||||
user_id: user.id,
|
||||
customer_id: customerId,
|
||||
amount: parseMoney(amount),
|
||||
credit_date: creditDate,
|
||||
description: description.trim() || null,
|
||||
},
|
||||
]);
|
||||
|
||||
if (creditError) throw creditError;
|
||||
|
||||
history.goBack();
|
||||
} catch (err: any) {
|
||||
console.error('Error saving credit:', err);
|
||||
setMessage(err.message || 'Could not save credit');
|
||||
setTimeout(() => setMessage(null), 4000);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader className="ion-no-border">
|
||||
<IonToolbar style={{ '--background': '#f0f4ff' }}>
|
||||
<IonButtons slot="start">
|
||||
<IonButton color="dark" onClick={() => history.goBack()}>
|
||||
<IonIcon icon={chevronBackOutline} slot="icon-only" />
|
||||
</IonButton>
|
||||
</IonButtons>
|
||||
<IonTitle className="bold">Add credit</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent className="ion-padding" style={{ '--background': '#f0f4ff' }}>
|
||||
<div className="form-card">
|
||||
<div className="pill-toggle" style={{ marginBottom: '8px' }}>
|
||||
<button
|
||||
className={`pill-toggle-btn ${mode === 'existing' ? 'active' : ''}`}
|
||||
onClick={() => setMode('existing')}
|
||||
disabled={customers.length === 0}
|
||||
>
|
||||
Existing Customer
|
||||
</button>
|
||||
<button
|
||||
className={`pill-toggle-btn ${mode === 'new' ? 'active' : ''}`}
|
||||
onClick={() => setMode('new')}
|
||||
>
|
||||
New Customer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'existing' ? (
|
||||
<div className="form-field">
|
||||
<IonLabel
|
||||
className="semibold"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Select Customer
|
||||
</IonLabel>
|
||||
<div className="input-wrapper">
|
||||
<IonSelect
|
||||
interface="action-sheet"
|
||||
placeholder="Choose a customer"
|
||||
value={selectedCustomerId}
|
||||
onIonChange={(e) => setSelectedCustomerId(e.detail.value)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{customers.map((c) => (
|
||||
<IonSelectOption key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</IonSelectOption>
|
||||
))}
|
||||
</IonSelect>
|
||||
</div>
|
||||
{errors.customer && (
|
||||
<IonLabel
|
||||
color="danger"
|
||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||
>
|
||||
{errors.customer}
|
||||
</IonLabel>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="form-field">
|
||||
<IonLabel
|
||||
className="semibold"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Customer Name
|
||||
</IonLabel>
|
||||
<div className="input-wrapper">
|
||||
<IonInput
|
||||
placeholder="e.g. John Doe"
|
||||
value={newName}
|
||||
onIonChange={(e) => setNewName(e.detail.value!)}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && (
|
||||
<IonLabel
|
||||
color="danger"
|
||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||
>
|
||||
{errors.name}
|
||||
</IonLabel>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<IonLabel
|
||||
className="semibold"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Phone (optional)
|
||||
</IonLabel>
|
||||
<div className="input-wrapper">
|
||||
<IonInput
|
||||
type="tel"
|
||||
placeholder="e.g. +1 234 567 890"
|
||||
value={phone}
|
||||
onIonChange={(e) => setPhone(e.detail.value!)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MoneyInput
|
||||
label="Credit amount"
|
||||
value={amount}
|
||||
onValueChange={(val) => {
|
||||
setAmount(val);
|
||||
if (errors.amount) setErrors({ ...errors, amount: '' });
|
||||
}}
|
||||
error={errors.amount}
|
||||
/>
|
||||
|
||||
<div className="form-field">
|
||||
<IonLabel
|
||||
className="semibold"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Credit date
|
||||
</IonLabel>
|
||||
<div className="input-wrapper">
|
||||
<IonInput
|
||||
type="date"
|
||||
value={creditDate}
|
||||
onIonChange={(e) => setCreditDate(e.detail.value!)}
|
||||
/>
|
||||
</div>
|
||||
{errors.date && (
|
||||
<IonLabel
|
||||
color="danger"
|
||||
style={{ fontSize: '12px', marginTop: '4px' }}
|
||||
>
|
||||
{errors.date}
|
||||
</IonLabel>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<IonLabel
|
||||
className="semibold"
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
display: 'block',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Description (optional)
|
||||
</IonLabel>
|
||||
<div
|
||||
className="input-wrapper"
|
||||
style={{ minHeight: '96px', padding: '8px 16px' }}
|
||||
>
|
||||
<IonTextarea
|
||||
placeholder="e.g. 2 bags of rice, sugar"
|
||||
value={description}
|
||||
onIonChange={(e) => setDescription(e.detail.value!)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-danger-soft)',
|
||||
color: 'var(--ion-color-danger-shade)',
|
||||
padding: '12px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
className="bold"
|
||||
style={{
|
||||
height: '52px',
|
||||
'--border-radius': 'var(--radius-pill)',
|
||||
marginTop: '16px',
|
||||
}}
|
||||
disabled={submitting || (loadingCustomers && mode === 'existing')}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{submitting ? <IonSpinner name="crescent" /> : 'Save credit'}
|
||||
</IonButton>
|
||||
</div>
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreditFormPage;
|
||||
Reference in New Issue
Block a user