Files
csbi/apps/csbie-ui/src/features/history/HistoryView.vue
T

495 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v'
import {
ArrowLeft,
Ban,
FileText,
Pencil,
ReceiptText,
RefreshCw,
ShieldCheck,
} from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderDetail, OrderPreview, OrderRow, TradeRecordRow } from '../../types/trading'
import { currencyForMarket } from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
const props = defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
cancelingOrderKey: string
orderHistoryLoaded: boolean
orderHistoryNotice: string
loadOrderDetail: (order: OrderRow) => Promise<OrderDetail>
loadTradeRecords: () => Promise<TradeRecordRow[]>
estimateOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<OrderPreview>
placeOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<void>
}>()
const emit = defineEmits<{
refresh: []
cancel: [order: OrderRow]
}>()
const cancelCandidate = ref<OrderRow | null>(null)
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) =>
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
const canShowUsDetail = (order: OrderRow) => isUsMarket(order.market)
const canCorrect = (order: OrderRow) =>
order.status === '注文中' &&
Boolean(order.orderNumber) &&
isUsMarket(order.market) &&
order.correctable !== false
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
const orderDetail = ref<OrderDetail | null>(null)
const orderDetailLoadingKey = ref('')
const orderActionError = ref('')
const tradeRecords = ref<TradeRecordRow[]>([])
const showTradeRecords = ref(false)
const tradeRecordsLoading = ref(false)
const correctionCandidate = ref<OrderRow | null>(null)
const correctionQuantityInput = ref('')
const correctionPriceCondition = ref<'market' | 'limit'>('limit')
const correctionPriceInput = ref('')
const correctionPreview = ref<OrderPreview | null>(null)
const correctionLoading = ref(false)
const correctionSubmitting = ref(false)
const orderActionKey = (order: OrderRow) => orderHistoryKey(order)
const numericInput = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const correctionDraft = () => {
const quantity = numericInput(correctionQuantityInput.value)
if (!quantity || quantity <= 0) throw new Error('数量を入力してください')
if (correctionPriceCondition.value === 'market') {
return { quantity, priceCondition: 'market' as const }
}
const price = numericInput(correctionPriceInput.value)
if (!price || price <= 0) throw new Error('指値を入力してください')
return { quantity, priceCondition: 'limit' as const, price }
}
const priceText = (value: number | null | undefined, market: string) =>
value == null ? '-' : currencyForMarket(value, market)
const tradeAmountText = (record: TradeRecordRow) =>
record.amount == null ? '-' : currencyForMarket(record.amount, record.market)
const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return
cancelCandidate.value = order
}
const confirmCancel = () => {
if (!cancelCandidate.value) return
emit('cancel', cancelCandidate.value)
cancelCandidate.value = null
}
const showOrderDetail = async (order: OrderRow) => {
if (!canShowUsDetail(order) || orderDetailLoadingKey.value) return
orderActionError.value = ''
orderDetailLoadingKey.value = orderActionKey(order)
try {
orderDetail.value = await props.loadOrderDetail(order)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文詳細の取得に失敗しました'
} finally {
orderDetailLoadingKey.value = ''
}
}
const loadTradeRecords = async () => {
if (tradeRecordsLoading.value) return
orderActionError.value = ''
tradeRecordsLoading.value = true
try {
tradeRecords.value = await props.loadTradeRecords()
showTradeRecords.value = true
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '約定履歴の取得に失敗しました'
} finally {
tradeRecordsLoading.value = false
}
}
const askCorrection = (order: OrderRow) => {
if (!canCorrect(order)) return
correctionCandidate.value = order
correctionQuantityInput.value = String(order.unexecutedQuantity ?? order.quantity ?? '')
correctionPriceCondition.value = order.price == null ? 'market' : 'limit'
correctionPriceInput.value = order.price == null ? '' : String(order.price)
correctionPreview.value = null
orderActionError.value = ''
}
const estimateCorrection = async () => {
if (!correctionCandidate.value || correctionLoading.value) return
orderActionError.value = ''
correctionLoading.value = true
try {
correctionPreview.value = await props.estimateOrderCorrection(
correctionCandidate.value,
correctionDraft(),
)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正の見積に失敗しました'
} finally {
correctionLoading.value = false
}
}
const confirmCorrection = async () => {
if (!correctionCandidate.value || correctionSubmitting.value) return
orderActionError.value = ''
correctionSubmitting.value = true
try {
await props.placeOrderCorrection(correctionCandidate.value, correctionDraft())
correctionCandidate.value = null
correctionPreview.value = null
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正に失敗しました'
} finally {
correctionSubmitting.value = false
}
}
</script>
<template>
<section :class="ui.panel">
<div :class="ui.panelHead">
<h2>取引履歴</h2>
<span :class="ui.rowActions">
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || tradeRecordsLoading"
@click="loadTradeRecords"
>
<Spinner v-if="tradeRecordsLoading" size="sm" />
<ReceiptText v-else class="h-4 w-4" aria-hidden="true" />
約定履歴
</button>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</span>
</div>
<p v-if="orderActionError" :class="ui.dialogNote">{{ orderActionError }}</p>
<div :class="ui.list">
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span>
<strong>{{ order.stock }}</strong>
<small>{{ order.code }} / {{ order.market }} / {{ order.date }}</small>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
</span>
<span>{{ orderQuantityText(order) }}</span>
<span>{{ orderAmountText(order) }}</span>
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</span>
<span :class="ui.rowActions">
<button
v-if="canShowUsDetail(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(orderDetailLoadingKey)"
@click="showOrderDetail(order)"
>
<Spinner v-if="orderDetailLoadingKey === orderActionKey(order)" size="sm" />
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
詳細
</button>
<button
v-if="canCorrect(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCorrection(order)"
>
<Pencil class="h-3.5 w-3.5" aria-hidden="true" />
訂正
</button>
<button
v-if="canCancel(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCancel(order)"
>
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
<Ban v-else class="h-3.5 w-3.5" aria-hidden="true" />
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
</button>
</span>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner />
</div>
<p v-else-if="!orders.length" :class="[ui.muted, 'py-8 text-center']">
{{
orderHistoryLoaded
? orderHistoryNotice
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
: '該当する注文履歴はありません'
: '注文履歴はまだ取得されていません'
}}
</p>
</div>
</section>
<AnimatePresence>
<UiModal
v-if="orderDetail"
key="order-detail-dialog"
eyebrow="注文詳細"
:title="orderDetail.stock"
@close="orderDetail = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ orderDetail.orderNumber ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>枝番</dt>
<dd>{{ orderDetail.orderSubNo ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>銘柄</dt>
<dd>{{ orderDetail.code }} / {{ orderDetail.market }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ orderDetail.side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ orderQuantityText(orderDetail) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>未約定数量</dt>
<dd>{{ orderDetail.unexecutedQuantity ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>価格</dt>
<dd>{{ priceText(orderDetail.price, orderDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>状態</dt>
<dd>{{ orderDetail.statusText ?? orderDetail.status }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderDetail.expiresAt ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>取消/訂正</dt>
<dd>
{{ orderDetail.cancelable === false ? '取消不可' : '取消可' }} /
{{ orderDetail.correctable === false ? '訂正不可' : '訂正可' }}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="orderDetail = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="showTradeRecords"
key="trade-records-dialog"
eyebrow="約定履歴"
title="米国株"
@close="showTradeRecords = false"
>
<div v-if="tradeRecords.length" :class="ui.confirmList">
<div v-for="record in tradeRecords" :key="record.id" :class="ui.row">
<span class="grid gap-1">
<strong>{{ record.stock }}</strong>
<small>{{ record.code }} / {{ record.market }} / {{ record.type }}</small>
<small :class="ui.muted">
{{ record.tradeDate ?? '-' }} 約定 / {{ record.valueDate ?? '-' }} 受渡
</small>
</span>
<span class="grid justify-items-end gap-1 text-right">
<strong>{{ tradeAmountText(record) }}</strong>
<small :class="ui.muted">
{{ record.quantity ?? '-' }}株 @ {{ priceText(record.price, record.market) }}
</small>
</span>
</div>
</div>
<p v-else :class="ui.dialogNote">該当する約定履歴はありません</p>
<div :class="ui.actions">
<UiButton variant="ghost" @click="showTradeRecords = false">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="correctionCandidate"
key="correction-dialog"
eyebrow="注文訂正"
:title="correctionCandidate.stock"
@close="correctionCandidate = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ correctionCandidate.orderNumber }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在数量</dt>
<dd>{{ orderQuantityText(correctionCandidate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在価格</dt>
<dd>{{ priceText(correctionCandidate.price, correctionCandidate.market) }}</dd>
</div>
</dl>
<div class="grid gap-3">
<label :class="ui.label">
数量
<input
v-model="correctionQuantityInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
<label :class="ui.label">
執行条件
<select
v-model="correctionPriceCondition"
:class="ui.input"
@change="correctionPreview = null"
>
<option value="limit">指値</option>
<option value="market">成行</option>
</select>
</label>
<label v-if="correctionPriceCondition === 'limit'" :class="ui.label">
指値
<input
v-model="correctionPriceInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
</div>
<dl v-if="correctionPreview" :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>見積数量</dt>
<dd>{{ correctionPreview.quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>見積金額</dt>
<dd>
{{
correctionPreview.price
? priceText(correctionPreview.price.value, correctionCandidate.market)
: '-'
}}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="correctionCandidate = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton :disabled="correctionLoading" @click="estimateCorrection">
<Spinner v-if="correctionLoading" size="sm" />
<ShieldCheck v-else class="h-4 w-4" aria-hidden="true" />
見積
</UiButton>
<UiButton
variant="danger"
:disabled="!correctionPreview || correctionSubmitting"
@click="confirmCorrection"
>
<Spinner v-if="correctionSubmitting" size="sm" />
<Pencil v-else class="h-4 w-4" aria-hidden="true" />
訂正
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="cancelCandidate"
key="cancel-order-dialog"
eyebrow="注文取消"
:title="cancelTitle"
@close="cancelCandidate = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ cancelCandidate.orderNumber }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ cancelCandidate.side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ orderQuantityText(cancelCandidate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文金額</dt>
<dd>{{ orderAmountText(cancelCandidate) }}</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="cancelCandidate = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton variant="danger" @click="confirmCancel">
<Ban class="h-4 w-4" aria-hidden="true" />
注文取消
</UiButton>
</div>
</UiModal>
</AnimatePresence>
</template>