feat: add manual signal execution to portfolio
Allow users to execute active KJB signals by selecting a portfolio, entering quantity and price, then creating the corresponding transaction and updating holdings. Signal status changes to 'executed' after completion. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e6160fffc6
commit
a7366d053e
@ -1,16 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
KJB Signal API endpoints.
|
KJB Signal API endpoints.
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.api.deps import CurrentUser
|
from app.api.deps import CurrentUser
|
||||||
from app.models.signal import Signal
|
from app.models.signal import Signal, SignalStatus, SignalType
|
||||||
from app.schemas.signal import SignalResponse
|
from app.models.portfolio import Holding, Transaction, TransactionType
|
||||||
|
from app.schemas.signal import SignalExecuteRequest, SignalResponse
|
||||||
|
from app.schemas.portfolio import TransactionResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/signal", tags=["signal"])
|
router = APIRouter(prefix="/api/signal", tags=["signal"])
|
||||||
|
|
||||||
@ -54,3 +56,94 @@ async def get_signal_history(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return signals
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{signal_id}/execute", response_model=dict)
|
||||||
|
async def execute_signal(
|
||||||
|
signal_id: int,
|
||||||
|
data: SignalExecuteRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Execute a signal by creating a portfolio transaction and updating signal status."""
|
||||||
|
from app.api.portfolio import _get_portfolio
|
||||||
|
|
||||||
|
# 1. Look up the signal and verify it's active
|
||||||
|
signal = db.query(Signal).filter(Signal.id == signal_id).first()
|
||||||
|
if not signal:
|
||||||
|
raise HTTPException(status_code=404, detail="Signal not found")
|
||||||
|
if signal.status != SignalStatus.ACTIVE:
|
||||||
|
raise HTTPException(status_code=400, detail="Signal is not active")
|
||||||
|
|
||||||
|
# 2. Verify portfolio ownership
|
||||||
|
portfolio = _get_portfolio(db, data.portfolio_id, current_user.id)
|
||||||
|
|
||||||
|
# 3. Map signal type to transaction type
|
||||||
|
if signal.signal_type == SignalType.BUY:
|
||||||
|
tx_type = TransactionType.BUY
|
||||||
|
else:
|
||||||
|
tx_type = TransactionType.SELL
|
||||||
|
|
||||||
|
# 4. Create transaction (reuse portfolio transaction logic)
|
||||||
|
transaction = Transaction(
|
||||||
|
portfolio_id=data.portfolio_id,
|
||||||
|
ticker=signal.ticker,
|
||||||
|
tx_type=tx_type,
|
||||||
|
quantity=data.quantity,
|
||||||
|
price=data.price,
|
||||||
|
executed_at=datetime.utcnow(),
|
||||||
|
memo=f"KJB signal #{signal.id}: {signal.signal_type.value}",
|
||||||
|
)
|
||||||
|
db.add(transaction)
|
||||||
|
|
||||||
|
# 5. Update holding
|
||||||
|
holding = db.query(Holding).filter(
|
||||||
|
Holding.portfolio_id == data.portfolio_id,
|
||||||
|
Holding.ticker == signal.ticker,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if tx_type == TransactionType.BUY:
|
||||||
|
if holding:
|
||||||
|
total_value = (holding.quantity * holding.avg_price) + (data.quantity * data.price)
|
||||||
|
new_quantity = holding.quantity + data.quantity
|
||||||
|
holding.quantity = new_quantity
|
||||||
|
holding.avg_price = total_value / new_quantity if new_quantity > 0 else 0
|
||||||
|
else:
|
||||||
|
holding = Holding(
|
||||||
|
portfolio_id=data.portfolio_id,
|
||||||
|
ticker=signal.ticker,
|
||||||
|
quantity=data.quantity,
|
||||||
|
avg_price=data.price,
|
||||||
|
)
|
||||||
|
db.add(holding)
|
||||||
|
elif tx_type == TransactionType.SELL:
|
||||||
|
if not holding or holding.quantity < data.quantity:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Insufficient quantity for {signal.ticker}"
|
||||||
|
)
|
||||||
|
holding.quantity -= data.quantity
|
||||||
|
if holding.quantity == 0:
|
||||||
|
db.delete(holding)
|
||||||
|
|
||||||
|
# 6. Update signal status to executed
|
||||||
|
signal.status = SignalStatus.EXECUTED
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(transaction)
|
||||||
|
db.refresh(signal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"transaction": {
|
||||||
|
"id": transaction.id,
|
||||||
|
"ticker": transaction.ticker,
|
||||||
|
"tx_type": transaction.tx_type.value,
|
||||||
|
"quantity": transaction.quantity,
|
||||||
|
"price": float(transaction.price),
|
||||||
|
"executed_at": transaction.executed_at.isoformat(),
|
||||||
|
},
|
||||||
|
"signal": {
|
||||||
|
"id": signal.id,
|
||||||
|
"status": signal.status.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@ -6,11 +6,17 @@ from decimal import Decimal
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.schemas.portfolio import FloatDecimal
|
from app.schemas.portfolio import FloatDecimal
|
||||||
|
|
||||||
|
|
||||||
|
class SignalExecuteRequest(BaseModel):
|
||||||
|
portfolio_id: int
|
||||||
|
quantity: int = Field(..., gt=0)
|
||||||
|
price: FloatDecimal = Field(..., gt=0)
|
||||||
|
|
||||||
|
|
||||||
class SignalType(str, Enum):
|
class SignalType(str, Enum):
|
||||||
BUY = "buy"
|
BUY = "buy"
|
||||||
SELL = "sell"
|
SELL = "sell"
|
||||||
|
|||||||
@ -9,8 +9,23 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle } from 'lucide-react';
|
import { Radio, History, RefreshCw, ArrowUpCircle, ArrowDownCircle, MinusCircle, Play } from 'lucide-react';
|
||||||
|
|
||||||
interface Signal {
|
interface Signal {
|
||||||
id: number;
|
id: number;
|
||||||
@ -26,6 +41,11 @@ interface Signal {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Portfolio {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
|
const signalTypeConfig: Record<string, { label: string; style: string; icon: typeof ArrowUpCircle }> = {
|
||||||
buy: {
|
buy: {
|
||||||
label: '매수',
|
label: '매수',
|
||||||
@ -77,6 +97,16 @@ export default function SignalsPage() {
|
|||||||
const [filterStartDate, setFilterStartDate] = useState('');
|
const [filterStartDate, setFilterStartDate] = useState('');
|
||||||
const [filterEndDate, setFilterEndDate] = useState('');
|
const [filterEndDate, setFilterEndDate] = useState('');
|
||||||
|
|
||||||
|
// Execute modal state
|
||||||
|
const [executeModalOpen, setExecuteModalOpen] = useState(false);
|
||||||
|
const [executeSignal, setExecuteSignal] = useState<Signal | null>(null);
|
||||||
|
const [portfolios, setPortfolios] = useState<Portfolio[]>([]);
|
||||||
|
const [selectedPortfolioId, setSelectedPortfolioId] = useState('');
|
||||||
|
const [executeQuantity, setExecuteQuantity] = useState('');
|
||||||
|
const [executePrice, setExecutePrice] = useState('');
|
||||||
|
const [executing, setExecuting] = useState(false);
|
||||||
|
const [executeError, setExecuteError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
try {
|
try {
|
||||||
@ -115,6 +145,15 @@ export default function SignalsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchPortfolios = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get<Portfolio[]>('/api/portfolios');
|
||||||
|
setPortfolios(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch portfolios:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
@ -140,6 +179,57 @@ export default function SignalsPage() {
|
|||||||
await fetchHistorySignals();
|
await fetchHistorySignals();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenExecuteModal = async (signal: Signal) => {
|
||||||
|
setExecuteSignal(signal);
|
||||||
|
setExecutePrice(signal.entry_price?.toString() || '');
|
||||||
|
setExecuteQuantity('');
|
||||||
|
setSelectedPortfolioId('');
|
||||||
|
setExecuteError('');
|
||||||
|
await fetchPortfolios();
|
||||||
|
setExecuteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecute = async () => {
|
||||||
|
if (!executeSignal || !selectedPortfolioId || !executeQuantity || !executePrice) {
|
||||||
|
setExecuteError('모든 필드를 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = parseInt(executeQuantity);
|
||||||
|
const price = parseFloat(executePrice);
|
||||||
|
|
||||||
|
if (isNaN(quantity) || quantity <= 0) {
|
||||||
|
setExecuteError('수량은 0보다 커야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(price) || price <= 0) {
|
||||||
|
setExecuteError('가격은 0보다 커야 합니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setExecuting(true);
|
||||||
|
setExecuteError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post(`/api/signal/${executeSignal.id}/execute`, {
|
||||||
|
portfolio_id: parseInt(selectedPortfolioId),
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
});
|
||||||
|
setExecuteModalOpen(false);
|
||||||
|
// Refresh signals list
|
||||||
|
if (showHistory) {
|
||||||
|
await fetchHistorySignals();
|
||||||
|
} else {
|
||||||
|
await fetchTodaySignals();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setExecuteError(err instanceof Error ? err.message : '실행에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderSignalTable = (signals: Signal[]) => (
|
const renderSignalTable = (signals: Signal[]) => (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
@ -154,6 +244,7 @@ export default function SignalsPage() {
|
|||||||
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">손절가</th>
|
<th scope="col" className="px-4 py-3 text-right text-sm font-medium text-muted-foreground">손절가</th>
|
||||||
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">사유</th>
|
<th scope="col" className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">사유</th>
|
||||||
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">상태</th>
|
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">상태</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-center text-sm font-medium text-muted-foreground">실행</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
@ -189,12 +280,24 @@ export default function SignalsPage() {
|
|||||||
<td className="px-4 py-3 text-center">
|
<td className="px-4 py-3 text-center">
|
||||||
<Badge className={statConf.style}>{statConf.label}</Badge>
|
<Badge className={statConf.style}>{statConf.label}</Badge>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{signal.status === 'active' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleOpenExecuteModal(signal)}
|
||||||
|
>
|
||||||
|
<Play className="h-3 w-3 mr-1" />
|
||||||
|
실행
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{signals.length === 0 && (
|
{signals.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-muted-foreground">
|
<td colSpan={10} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
신호가 없습니다.
|
신호가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -355,6 +458,105 @@ export default function SignalsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Execute Signal Modal */}
|
||||||
|
<Dialog open={executeModalOpen} onOpenChange={setExecuteModalOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>신호 실행</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
매매 신호를 포트폴리오에 반영합니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{executeSignal && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Signal info */}
|
||||||
|
<div className="rounded-md border p-3 space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">종목</span>
|
||||||
|
<span className="font-medium">{executeSignal.name || executeSignal.ticker} ({executeSignal.ticker})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">신호 유형</span>
|
||||||
|
<Badge className={signalTypeConfig[executeSignal.signal_type]?.style || 'bg-muted'}>
|
||||||
|
{signalTypeConfig[executeSignal.signal_type]?.label || executeSignal.signal_type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">추천 진입가</span>
|
||||||
|
<span className="font-mono">{formatPrice(executeSignal.entry_price)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">목표가</span>
|
||||||
|
<span className="font-mono text-green-600">{formatPrice(executeSignal.target_price)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">손절가</span>
|
||||||
|
<span className="font-mono text-red-600">{formatPrice(executeSignal.stop_loss_price)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portfolio selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>포트폴리오</Label>
|
||||||
|
<Select value={selectedPortfolioId} onValueChange={setSelectedPortfolioId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="포트폴리오 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{portfolios.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id.toString()}>
|
||||||
|
{p.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quantity */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="exec-quantity">수량 (주)</Label>
|
||||||
|
<Input
|
||||||
|
id="exec-quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={executeQuantity}
|
||||||
|
onChange={(e) => setExecuteQuantity(e.target.value)}
|
||||||
|
placeholder="매매 수량 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="exec-price">체결가</Label>
|
||||||
|
<Input
|
||||||
|
id="exec-price"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={executePrice}
|
||||||
|
onChange={(e) => setExecutePrice(e.target.value)}
|
||||||
|
placeholder="실제 체결 가격 입력"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{executeError && (
|
||||||
|
<p className="text-sm text-red-600">{executeError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setExecuteModalOpen(false)} disabled={executing}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExecute} disabled={executing}>
|
||||||
|
{executing ? '실행 중...' : '실행'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
122
frontend/src/components/ui/dialog.tsx
Normal file
122
frontend/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user