frontend refactor

This commit is contained in:
Egor Pozharov
2026-04-14 17:10:13 +06:00
parent 0540218332
commit 9b90a8aa7f
14 changed files with 328 additions and 127 deletions
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { MapPin, Phone, Package, Store, Calendar, MessageSquare, CheckCircle2, Circle, CheckSquare } from 'lucide-react';
import type { Delivery } from '../../types';
import { pickupLocationLabels } from '../../types';
@@ -11,7 +12,7 @@ interface DeliveryCardProps {
onDelete: (id: string) => void;
}
export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
export const DeliveryCard = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryCardProps) => {
const handleAddressClick = () => {
const encodedAddress = encodeURIComponent(delivery.address);
window.open(`https://maps.google.com/?q=${encodedAddress}`, '_blank');
@@ -132,4 +133,6 @@ export const DeliveryCard = ({ delivery, onStatusChange, onEdit, onDelete }: Del
</div>
</Card>
);
};
});
DeliveryCard.displayName = 'DeliveryCard';
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { Button, Input, Select, Modal } from '../ui';
import { pickupOptions } from '../../constants/pickup';
import { formatDateForInput, parseDateFromInput, getTodayFrontend } from '../../utils/date';
import type { Delivery, PickupLocation, DeliveryStatus } from '../../types';
import { pickupLocationLabels } from '../../types';
interface DeliveryFormProps {
isOpen: boolean;
@@ -12,16 +13,12 @@ interface DeliveryFormProps {
isSubmitting?: boolean;
}
const pickupOptions: { value: PickupLocation; label: string }[] = [
{ value: 'warehouse', label: pickupLocationLabels.warehouse },
{ value: 'symbat', label: pickupLocationLabels.symbat },
{ value: 'nursaya', label: pickupLocationLabels.nursaya },
{ value: 'galaktika', label: pickupLocationLabels.galaktika },
];
// Phone validation regex for Kazakhstan numbers
const PHONE_REGEX = /^\+7\s?\(?\d{3}\)?\s?\d{3}[\s-]?\d{2}[\s-]?\d{2}$/;
export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDate, isSubmitting }: DeliveryFormProps) => {
const [formData, setFormData] = useState({
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse' as PickupLocation,
productName: '',
address: '',
@@ -50,34 +47,39 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
}
}, [initialData, defaultDate, isOpen]);
const handleSubmit = (e: React.FormEvent) => {
const validatePhone = useCallback((phone: string): boolean => {
if (!phone) return false;
return PHONE_REGEX.test(phone);
}, []);
const isPhoneValid = !formData.phone || validatePhone(formData.phone);
const isAdditionalPhoneValid = !formData.additionalPhone || validatePhone(formData.additionalPhone);
const isFormValid = formData.productName && formData.address && formData.phone && isPhoneValid;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
if (!initialData) {
setFormData({
date: defaultDate || new Date().toLocaleDateString('ru-RU').split('.').join('-'),
pickupLocation: 'warehouse',
productName: '',
address: '',
phone: '',
additionalPhone: '',
hasElevator: false,
comment: '',
status: 'new',
});
if (!isFormValid) return;
try {
await onSubmit(formData);
if (!initialData) {
setFormData({
date: defaultDate || getTodayFrontend(),
pickupLocation: 'warehouse',
productName: '',
address: '',
phone: '',
additionalPhone: '',
hasElevator: false,
comment: '',
status: 'new',
});
}
onClose();
} catch {
// Error is handled by parent, keep form open
}
onClose();
};
const formatDateForInput = (dateStr: string) => {
const [day, month, year] = dateStr.split('-');
return `${year}-${month}-${day}`;
};
const formatDateFromInput = (dateStr: string) => {
const [year, month, day] = dateStr.split('-');
return `${day}-${month}-${year}`;
};
return (
<Modal
@@ -89,7 +91,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
<Button variant="ghost" onClick={onClose} disabled={isSubmitting}>
Отмена
</Button>
<Button type="submit" form="delivery-form" disabled={isSubmitting}>
<Button type="submit" form="delivery-form" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Сохранение...' : initialData ? 'Сохранить' : 'Создать'}
</Button>
</>
@@ -103,7 +105,7 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
<input
type="date"
value={formatDateForInput(formData.date)}
onChange={(e) => setFormData({ ...formData, date: formatDateFromInput(e.target.value) })}
onChange={(e) => setFormData({ ...formData, date: parseDateFromInput(e.target.value) })}
className="w-full px-3 py-2 bg-[#f5f3f5] border border-[#c5c6cd] rounded-md text-[#1b1b1d] focus:outline-none focus:ring-2 focus:ring-[#1B263B] focus:border-transparent transition-colors"
required
/>
@@ -144,7 +146,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
}}
placeholder="+7 (776)-567-89-01"
required
aria-invalid={!isPhoneValid}
aria-describedby={!isPhoneValid ? 'phone-error' : undefined}
/>
{!isPhoneValid && formData.phone && (
<p id="phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
<Input
label="Дополнительный номер телефона"
@@ -157,7 +166,14 @@ export const DeliveryForm = ({ isOpen, onClose, onSubmit, initialData, defaultDa
}
}}
placeholder="+7 (776)-567-89-01"
aria-invalid={!isAdditionalPhoneValid}
aria-describedby={!isAdditionalPhoneValid ? 'additional-phone-error' : undefined}
/>
{!isAdditionalPhoneValid && formData.additionalPhone && (
<p id="additional-phone-error" className="text-sm text-red-500 mt-1">
Введите корректный номер: +7 (XXX) XXX-XX-XX
</p>
)}
<div className="flex items-center gap-2">
<input
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { Plus, LayoutGrid, Table as TableIcon } from 'lucide-react';
import { DeliveryCard } from './DeliveryCard';
import { DeliveryRow } from './DeliveryRow';
@@ -17,8 +17,8 @@ interface DeliveryListProps {
export const DeliveryList = ({ deliveries, onStatusChange, onEdit, onDelete, onAdd, date }: DeliveryListProps) => {
const [viewMode, setViewMode] = useState<'kanban' | 'table'>('kanban');
const newDeliveries = deliveries.filter(d => d.status === 'new');
const deliveredDeliveries = deliveries.filter(d => d.status === 'delivered');
const newDeliveries = useMemo(() => deliveries.filter(d => d.status === 'new'), [deliveries]);
const deliveredDeliveries = useMemo(() => deliveries.filter(d => d.status === 'delivered'), [deliveries]);
return (
<div className="space-y-4">
@@ -1,3 +1,4 @@
import { memo } from 'react';
import { MapPin, Phone } from 'lucide-react';
import type { Delivery } from '../../types';
import { pickupLocationLabels } from '../../types';
@@ -10,7 +11,7 @@ interface DeliveryRowProps {
onDelete: (id: string) => void;
}
export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
export const DeliveryRow = memo(({ delivery, onStatusChange, onEdit, onDelete }: DeliveryRowProps) => {
const handleAddressClick = (e: React.MouseEvent) => {
e.stopPropagation();
const encodedAddress = encodeURIComponent(delivery.address);
@@ -75,4 +76,6 @@ export const DeliveryRow = ({ delivery, onStatusChange, onEdit, onDelete }: Deli
</td>
</tr>
);
};
});
DeliveryRow.displayName = 'DeliveryRow';
+45
View File
@@ -0,0 +1,45 @@
import { useToastStore } from '../../stores/toastStore';
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const styles = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
};
export const ToastContainer = () => {
const { toasts, removeToast } = useToastStore();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => {
const Icon = icons[toast.type];
return (
<div
key={toast.id}
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg min-w-[300px] animate-in slide-in-from-right ${styles[toast.type]}`}
role="alert"
>
<Icon size={20} />
<p className="flex-1 text-sm">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="p-1 hover:bg-black/5 rounded transition-colors"
aria-label="Закрыть"
>
<X size={16} />
</button>
</div>
);
})}
</div>
);
};