feat: add portfolio edit/delete UI with confirmation dialogs
Add hover-visible edit (rename) and delete buttons to portfolio cards on the list page, with modal dialogs for name editing and delete confirmation. Uses existing PUT/DELETE API endpoints. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
815f255ff5
commit
ee0de0504c
@ -5,9 +5,21 @@ import { useRouter } from 'next/navigation';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
import { DashboardLayout } from '@/components/layout/dashboard-layout';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { PortfolioCard } from '@/components/portfolio/portfolio-card';
|
import { PortfolioCard } from '@/components/portfolio/portfolio-card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Pencil, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
interface HoldingWithValue {
|
interface HoldingWithValue {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@ -33,6 +45,17 @@ export default function PortfolioListPage() {
|
|||||||
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<Portfolio | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
|
|
||||||
|
// Delete modal state
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Portfolio | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
@ -88,6 +111,53 @@ export default function PortfolioListPage() {
|
|||||||
return (portfolio.total_profit_loss / portfolio.total_invested) * 100;
|
return (portfolio.total_profit_loss / portfolio.total_invested) * 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (e: React.MouseEvent, portfolio: Portfolio) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditTarget(portfolio);
|
||||||
|
setEditName(portfolio.name);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editTarget || !editName.trim()) return;
|
||||||
|
setEditSaving(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/api/portfolios/${editTarget.id}`, { name: editName.trim() });
|
||||||
|
toast.success('포트폴리오 이름이 변경되었습니다.');
|
||||||
|
setEditModalOpen(false);
|
||||||
|
await fetchPortfolios();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update portfolio:', err);
|
||||||
|
toast.error('포트폴리오 수정에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setEditSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenDelete = (e: React.MouseEvent, portfolio: Portfolio) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDeleteTarget(portfolio);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/portfolios/${deleteTarget.id}`);
|
||||||
|
toast.success('포트폴리오가 삭제되었습니다.');
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setPortfolios((prev) => prev.filter((p) => p.id !== deleteTarget.id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete portfolio:', err);
|
||||||
|
toast.error('포트폴리오 삭제에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@ -121,15 +191,32 @@ export default function PortfolioListPage() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{portfolios.map((portfolio) => (
|
{portfolios.map((portfolio) => (
|
||||||
<PortfolioCard
|
<div key={portfolio.id} className="relative group">
|
||||||
key={portfolio.id}
|
<PortfolioCard
|
||||||
id={portfolio.id}
|
id={portfolio.id}
|
||||||
name={portfolio.name}
|
name={portfolio.name}
|
||||||
portfolioType={portfolio.portfolio_type}
|
portfolioType={portfolio.portfolio_type}
|
||||||
totalValue={portfolio.total_value ?? null}
|
totalValue={portfolio.total_value ?? null}
|
||||||
returnPercent={calculateReturnPercent(portfolio)}
|
returnPercent={calculateReturnPercent(portfolio)}
|
||||||
holdings={portfolio.holdings ?? []}
|
holdings={portfolio.holdings ?? []}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute top-3 right-14 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleOpenEdit(e, portfolio)}
|
||||||
|
className="p-1.5 rounded-md bg-background/80 border border-border hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
|
title="이름 변경"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleOpenDelete(e, portfolio)}
|
||||||
|
className="p-1.5 rounded-md bg-background/80 border border-border hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -161,6 +248,57 @@ export default function PortfolioListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Dialog open={editModalOpen} onOpenChange={setEditModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>포트폴리오 이름 변경</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
포트폴리오의 이름을 수정합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="edit-name">이름</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-name"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
placeholder="포트폴리오 이름"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveEdit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditModalOpen(false)} disabled={editSaving}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveEdit} disabled={editSaving || !editName.trim()}>
|
||||||
|
{editSaving ? '저장 중...' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
<Dialog open={deleteModalOpen} onOpenChange={setDeleteModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>포트폴리오 삭제</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
"{deleteTarget?.name}" 포트폴리오를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteModalOpen(false)} disabled={deleting}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleConfirmDelete} disabled={deleting}>
|
||||||
|
{deleting ? '삭제 중...' : '삭제'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user