init feature

This commit is contained in:
arrelin
2025-12-24 15:38:36 +03:00
parent 0fdc20e750
commit fcd4199cbd
15 changed files with 994 additions and 2 deletions

View File

@@ -11,6 +11,11 @@ import type {
CreateExpenseRequest,
VerifyFamilyPasswordRequest,
VerifyFamilyPasswordResponse,
ShoppingItem,
CreateShoppingItemRequest,
UpdateShoppingItemRequest,
MarkAsPurchasedRequest,
BulkOperationResponse,
} from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@@ -87,3 +92,29 @@ export const expenseApi = {
getRemainingLimit: (familyId: number, categoryId: number) =>
apiClient.get<RemainingLimit>(`/families/${familyId}/categories/${categoryId}/remaining`),
};
export const shoppingItemApi = {
getAllByFamily: (familyId: number) =>
apiClient.get<ShoppingItem[]>(`/families/${familyId}/shopping-items`),
getById: (familyId: number, itemId: number) =>
apiClient.get<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}`),
create: (familyId: number, data: CreateShoppingItemRequest) =>
apiClient.post<ShoppingItem>(`/families/${familyId}/shopping-items`, data),
update: (familyId: number, itemId: number, data: UpdateShoppingItemRequest) =>
apiClient.put<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}`, data),
delete: (familyId: number, itemId: number) =>
apiClient.delete(`/families/${familyId}/shopping-items/${itemId}`),
markAsPurchased: (familyId: number, itemId: number, data: MarkAsPurchasedRequest) =>
apiClient.patch<ShoppingItem>(`/families/${familyId}/shopping-items/${itemId}/purchased`, data),
markAllAsPurchased: (familyId: number) =>
apiClient.post<BulkOperationResponse>(`/families/${familyId}/shopping-items/mark-all-purchased`),
clearAll: (familyId: number) =>
apiClient.delete<BulkOperationResponse>(`/families/${familyId}/shopping-items/clear-all`),
};

View File

@@ -0,0 +1,82 @@
import { X, AlertTriangle } from 'lucide-react';
interface ConfirmModalProps {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
variant?: 'danger' | 'warning' | 'info';
}
export default function ConfirmModal({
title,
message,
confirmText = 'Подтвердить',
cancelText = 'Отмена',
onConfirm,
onCancel,
variant = 'danger',
}: ConfirmModalProps) {
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: 'bg-red-100 text-red-600',
confirmButton: 'bg-gradient-to-r from-red-500 to-red-600 hover:shadow-lg',
};
case 'warning':
return {
icon: 'bg-yellow-100 text-yellow-600',
confirmButton: 'bg-gradient-to-r from-yellow-500 to-yellow-600 hover:shadow-lg',
};
case 'info':
return {
icon: 'bg-blue-100 text-blue-600',
confirmButton: 'bg-gradient-to-r from-blue-500 to-blue-600 hover:shadow-lg',
};
}
};
const styles = getVariantStyles();
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full overflow-hidden animate-scale-in">
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-2xl ${styles.icon}`}>
<AlertTriangle className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600">{message}</p>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 rounded-xl transition-all"
>
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
</div>
<div className="px-6 pb-6 flex gap-3">
<button
onClick={onCancel}
className="flex-1 px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-semibold"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`flex-1 px-6 py-3 text-white rounded-2xl transition-all font-semibold ${styles.confirmButton}`}
>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,373 @@
import { useEffect, useState } from 'react';
import { shoppingItemApi } from '../api/client';
import type { ShoppingItem } from '../types';
import {
X,
Plus,
Trash2,
ShoppingCart,
Check,
Loader2,
Pencil,
} from 'lucide-react';
import ConfirmModal from './ConfirmModal';
interface ShoppingListModalProps {
familyId: number;
onClose: () => void;
}
type ConfirmAction =
| { type: 'delete-item'; itemId: number }
| { type: 'mark-all' }
| { type: 'clear-all' };
export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) {
const [items, setItems] = useState<ShoppingItem[]>([]);
const [loading, setLoading] = useState(true);
const [newItemName, setNewItemName] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
useEffect(() => {
loadItems();
}, [familyId]);
const loadItems = async () => {
try {
setLoading(true);
const response = await shoppingItemApi.getAllByFamily(familyId);
setItems(response.data);
} catch (err) {
console.error('Error loading shopping items:', err);
alert('Ошибка загрузки списка покупок');
} finally {
setLoading(false);
}
};
const handleAddItem = async () => {
if (!newItemName.trim()) return;
try {
await shoppingItemApi.create(familyId, { name: newItemName });
setNewItemName('');
loadItems();
} catch (err) {
console.error('Error adding item:', err);
alert('Ошибка добавления покупки');
}
};
const handleTogglePurchased = async (itemId: number, currentStatus: boolean) => {
try {
await shoppingItemApi.markAsPurchased(familyId, itemId, { is_purchased: !currentStatus });
loadItems();
} catch (err) {
console.error('Error toggling purchased status:', err);
alert('Ошибка изменения статуса');
}
};
const handleDeleteItem = (itemId: number) => {
setConfirmAction({ type: 'delete-item', itemId });
};
const executeDeleteItem = async (itemId: number) => {
try {
await shoppingItemApi.delete(familyId, itemId);
loadItems();
} catch (err) {
console.error('Error deleting item:', err);
alert('Ошибка удаления покупки');
}
};
const handleStartEdit = (item: ShoppingItem) => {
setEditingId(item.id);
setEditingName(item.name);
};
const handleSaveEdit = async (itemId: number) => {
if (!editingName.trim()) return;
try {
await shoppingItemApi.update(familyId, itemId, { name: editingName });
setEditingId(null);
setEditingName('');
loadItems();
} catch (err) {
console.error('Error updating item:', err);
alert('Ошибка обновления покупки');
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditingName('');
};
const handleMarkAllPurchased = () => {
setConfirmAction({ type: 'mark-all' });
};
const executeMarkAllPurchased = async () => {
try {
await shoppingItemApi.markAllAsPurchased(familyId);
loadItems();
} catch (err) {
console.error('Error marking all as purchased:', err);
alert('Ошибка обновления списка');
}
};
const handleClearAll = () => {
setConfirmAction({ type: 'clear-all' });
};
const executeClearAll = async () => {
try {
await shoppingItemApi.clearAll(familyId);
loadItems();
} catch (err) {
console.error('Error clearing all items:', err);
alert('Ошибка очистки списка');
}
};
const handleConfirm = () => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'delete-item':
executeDeleteItem(confirmAction.itemId);
break;
case 'mark-all':
executeMarkAllPurchased();
break;
case 'clear-all':
executeClearAll();
break;
}
setConfirmAction(null);
};
const getConfirmModalContent = () => {
if (!confirmAction) return null;
switch (confirmAction.type) {
case 'delete-item':
return {
title: 'Удалить покупку?',
message: 'Покупка будет удалена из списка безвозвратно.',
confirmText: 'Удалить',
};
case 'mark-all':
return {
title: 'Пометить все как купленные?',
message: 'Все покупки в списке будут отмечены как купленные.',
confirmText: 'Пометить',
variant: 'info' as const,
};
case 'clear-all':
return {
title: 'Очистить список?',
message: 'Все покупки будут удалены из списка безвозвратно.',
confirmText: 'Очистить',
};
}
};
const unpurchasedItems = items.filter(item => !item.is_purchased);
const purchasedItems = items.filter(item => item.is_purchased);
const confirmContent = getConfirmModalContent();
return (
<>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="bg-gradient-to-r from-green-500 to-emerald-600 p-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-3 bg-white/20 backdrop-blur-md rounded-2xl">
<ShoppingCart className="w-8 h-8 text-white" />
</div>
<h2 className="text-3xl font-bold text-white">Список покупок</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/20 rounded-xl transition-all"
>
<X className="w-6 h-6 text-white" />
</button>
</div>
<div className="p-6 flex-1 overflow-y-auto">
<div className="mb-6">
<div className="flex gap-2">
<input
type="text"
placeholder="Добавить покупку..."
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleAddItem()}
className="flex-1 px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-green-500 focus:ring-2 focus:ring-green-200 transition-all"
/>
<button
onClick={handleAddItem}
className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold flex items-center gap-2"
>
<Plus className="w-5 h-5" />
<span className="hidden sm:inline">Добавить</span>
</button>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
</div>
) : (
<div className="space-y-6">
{unpurchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">К покупке</h3>
<div className="space-y-2">
{unpurchasedItems.map((item) => (
<div
key={item.id}
className="bg-gray-50 p-4 rounded-2xl border-2 border-gray-200 hover:border-green-300 transition-all"
>
{editingId === item.id ? (
<div className="flex gap-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSaveEdit(item.id)}
className="flex-1 px-3 py-2 border-2 border-green-300 rounded-xl focus:border-green-500 focus:ring-2 focus:ring-green-200"
autoFocus
/>
<button
onClick={() => handleSaveEdit(item.id)}
className="px-4 py-2 bg-green-500 text-white rounded-xl hover:bg-green-600 transition-all"
>
<Check className="w-5 h-5" />
</button>
<button
onClick={handleCancelEdit}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-xl hover:bg-gray-400 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 border-2 border-gray-400 rounded-lg hover:border-green-500 transition-all"
/>
<span className="text-gray-800 font-medium">{item.name}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleStartEdit(item)}
className="p-2 hover:bg-gray-200 rounded-xl transition-all"
>
<Pencil className="w-4 h-4 text-gray-600" />
</button>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{purchasedItems.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-3">Куплено</h3>
<div className="space-y-2">
{purchasedItems.map((item) => (
<div
key={item.id}
className="bg-green-50 p-4 rounded-2xl border-2 border-green-200"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={() => handleTogglePurchased(item.id, item.is_purchased)}
className="w-6 h-6 bg-green-500 border-2 border-green-500 rounded-lg flex items-center justify-center hover:bg-green-600 transition-all"
>
<Check className="w-4 h-4 text-white" />
</button>
<span className="text-gray-500 line-through">{item.name}</span>
</div>
<button
onClick={() => handleDeleteItem(item.id)}
className="p-2 hover:bg-red-100 rounded-xl transition-all"
>
<Trash2 className="w-4 h-4 text-red-500" />
</button>
</div>
</div>
))}
</div>
</div>
)}
{items.length === 0 && (
<div className="text-center py-12 text-gray-500">
<ShoppingCart className="w-16 h-16 mx-auto mb-4 opacity-30" />
<p className="text-lg">Список покупок пуст</p>
</div>
)}
</div>
)}
</div>
{items.length > 0 && (
<div className="p-6 border-t-2 border-gray-200 bg-gray-50">
<div className="flex gap-3">
<button
onClick={handleMarkAllPurchased}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Check className="w-5 h-5" />
Все куплено
</button>
<button
onClick={handleClearAll}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold"
>
<Trash2 className="w-5 h-5" />
Очистить
</button>
</div>
</div>
)}
</div>
</div>
{confirmContent && (
<ConfirmModal
title={confirmContent.title}
message={confirmContent.message}
confirmText={confirmContent.confirmText}
onConfirm={handleConfirm}
onCancel={() => setConfirmAction(null)}
variant={confirmContent.variant || 'danger'}
/>
)}
</>
);
}

View File

@@ -17,7 +17,9 @@ import {
History,
Calendar,
MessageSquare,
ShoppingCart,
} from 'lucide-react';
import ShoppingListModal from '../components/ShoppingListModal';
export default function FamilyView() {
const { familyId } = useParams<{ familyId: string }>();
@@ -39,6 +41,7 @@ export default function FamilyView() {
const [showHistory, setShowHistory] = useState<number | null>(null);
const [categoryExpenses, setCategoryExpenses] = useState<Expense[]>([]);
const [showShoppingList, setShowShoppingList] = useState(false);
useEffect(() => {
if (!familyId) {
@@ -241,7 +244,7 @@ export default function FamilyView() {
{selectedFamily?.name || 'Семья'}
</h1>
<div className="max-w-2xl mx-auto glass-effect rounded-2xl shadow-lg p-5">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-center">
<p className="text-gray-600 font-medium text-sm mb-2">Общий лимит</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">
@@ -255,6 +258,13 @@ export default function FamilyView() {
</p>
</div>
</div>
<button
onClick={() => setShowShoppingList(true)}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold"
>
<ShoppingCart className="w-5 h-5" />
Список покупок
</button>
</div>
</div>
</div>
@@ -510,6 +520,13 @@ export default function FamilyView() {
)}
</div>
</div>
{showShoppingList && familyId && (
<ShoppingListModal
familyId={parseInt(familyId)}
onClose={() => setShowShoppingList(false)}
/>
)}
</div>
);
}

View File

@@ -56,3 +56,27 @@ export interface CreateExpenseRequest {
amount: number;
description?: string;
}
export interface ShoppingItem {
id: number;
family_id: number;
name: string;
is_purchased: boolean;
created_at: string;
}
export interface CreateShoppingItemRequest {
name: string;
}
export interface UpdateShoppingItemRequest {
name: string;
}
export interface MarkAsPurchasedRequest {
is_purchased: boolean;
}
export interface BulkOperationResponse {
affected_rows: number;
}