Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@tanstack/react-query": "^5.56.2",
"@tanstack/react-query-devtools": "^5.56.2",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"framer-motion": "^11.5.4",
"lightweight-charts": "^4.2.1",
"lucide-react": "^0.468.0",
Expand Down
34 changes: 34 additions & 0 deletions src/api/company-details/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {
Consensus,
FinancialRatio,
RelativeNews,
} from "@/types/company-details";

import makeApiRequest from "../make-api-request";

// 기업 상세정보의 재무제표 조회
export async function getFinancialStatements(
stockName: string,
): Promise<FinancialRatio[]> {
return makeApiRequest(
"GET",
`/search/financialRatio?stockName=${stockName}`,
{},
);
}

// 기업 관련 뉴스 조회
export async function getRelativeNews(
stockName: string,
): Promise<RelativeNews[]> {
return makeApiRequest("GET", `/search/news?stockName=${stockName}`, {});
}

// 기업 컨센서스
export async function getConsensus(stockName: string): Promise<Consensus> {
return makeApiRequest(
"GET",
`/search/investmentRecommendation?stockName=${stockName}`,
{},
);
}
8 changes: 2 additions & 6 deletions src/api/make-api-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@ export default async function makeApiRequest<T, R = string>(
method: "GET" | "POST" | "PUT" | "DELETE",
endpoint: string,
options: {
token: string | null;
token?: string | null;
data?: T;
responseType?: "json" | "text";
},
): Promise<R> {
const { token, data, responseType = "json" } = options;

if (!token) {
throw new Error("로그인이 필요합니다.");
}

const headers: HeadersInit = {
Authorization: `Bearer ${token}`,
...(data && { "Content-Type": "application/json" }),
...(token && { Authorization: `Bearer ${token}` }),
};

const config: RequestInit = {
Expand Down
13 changes: 13 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,16 @@ body {
list-style-position: inside;
line-height: 24px;
}

.tooltip-container::after {
content: "";
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #11e977;
}
2 changes: 1 addition & 1 deletion src/app/search/[id]/_components/candle-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { useEffect, useRef, useState } from "react";

import { ChartDTO, VolumeDTO } from "../types";
import LoadingSpinner from "./loading-spiner";
import LoadingSpinner from "./loading-spinner";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소한거 좋아요

import PriceTooltip from "./price-tooltip";

interface Props {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,79 @@
export default function Consensus() {
import React from "react";

import categories from "@/constants/company-details";
import { Consensus as ConsensusType } from "@/types/company-details";
import {
calculateDifferencePercentage,
getRelativePosition,
} from "@/utils/calculate-consensus";
import cn from "@/utils/cn";

interface ConsensusProps {
investmentRecommendationData: ConsensusType | undefined;
}

export default function Consensus({
investmentRecommendationData,
}: ConsensusProps) {
if (!investmentRecommendationData) {
return (
<div className="flex items-center justify-center text-gray-500">
데이터를 로드하는 중입니다...
</div>
);
}

const difference = calculateDifferencePercentage(
investmentRecommendationData,
);
const cappedDifference = Math.max(Math.min(difference, 100), -100);

return (
<div className="w-full">
<div className="mb-10 text-16-500">컨센서스</div>
<div className="flex w-full justify-between gap-8 text-center text-14-500 text-gray-100">
<div className="flex-1">
<div className="mb-6 h-9 w-full bg-green-900" />
<span>적극 매도</span>
</div>
<div className="flex-1">
<div className="mb-6 h-9 w-full bg-green-900" />
<span>매도</span>
</div>
<div className="flex-1">
<div className="mb-6 h-9 w-full bg-green-900" />
<span>중립</span>
</div>
<div className="flex-1">
<div className="mb-6 h-9 w-full bg-green-900" />
<span>매수</span>
</div>
<div className="flex-1">
<div className="mb-6 h-9 w-full bg-green-900" />
<span>적극 매수</span>
</div>
<>
<div className="mb-8 mt-13 text-16-500 text-black">컨센서스</div>
<div className="relative flex w-full justify-between gap-8 text-center text-14-500 text-gray-100">
{categories.map((category, index) => {
const isActive =
cappedDifference > category.range[0] &&
cappedDifference <= category.range[1];

const relativePosition = getRelativePosition(
category.range,
cappedDifference,
);

return (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className="relative flex-1">
{isActive && (
<div
className="absolute bottom-36 w-max -translate-x-1/2"
style={{
left: `${relativePosition}%`,
}}
>
<div className="flex flex-col items-center">
<div className="rounded bg-lime-300 px-7 py-3 text-14-500 text-black">
{difference.toFixed(2)}%
</div>
<div
className="size-0 border-x-8 border-t-8 border-x-transparent border-t-lime-300"
style={{ marginTop: "-1px" }}
/>
</div>
</div>
)}
<div
className={cn(
"mb-6 h-9 w-full transition-colors",
isActive ? "bg-lime-300" : "bg-green-900",
)}
/>
<span>{category.label}</span>
</div>
);
})}
</div>
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
"use client";

type FinancialRatio = {
stockAccountingYearMonth: string;
grossMarginRatio: number;
businessProfitRate: number;
netInterestRate: number;
roeValue: number;
earningsPerShare: number;
salesPerShare: number;
bookValuePerShare: number;
reserveRate: number;
liabilityRate: number;
};
import { useQuery } from "@tanstack/react-query";

import { getFinancialStatements } from "@/api/company-details";
import { useStockInfoContext } from "@/context/stock-info-context";
import { FinancialRatio } from "@/types/company-details";

import LoadingSpinner from "../../loading-spinner";

type FinancialRatioKey = keyof FinancialRatio;

type NumberKeys = {
[K in keyof FinancialRatio]: FinancialRatio[K] extends number ? K : never;
}[keyof FinancialRatio];
[K in FinancialRatioKey]: FinancialRatio[K] extends number ? K : never;
}[FinancialRatioKey];

type FinancialMetric = {
label: string;
Expand All @@ -24,15 +21,27 @@ type FinancialMetric = {
};

const FINANCIAL_METRICS: FinancialMetric[] = [
{ label: "총마진율", key: "grossMarginRatio", isPercentage: true },
{ label: "사업 수익률", key: "businessProfitRate", isPercentage: true },
{ label: "순이자율", key: "netInterestRate", isPercentage: true },
{ label: "ROE", key: "roeValue", isPercentage: true },
{ label: "EPS(주당순이익)", key: "earningsPerShare" },
{ label: "SPS(주당매출액)", key: "salesPerShare" },
{ label: "BPS(주당순자산)", key: "bookValuePerShare" },
{ label: "주식유보율", key: "reserveRate", isPercentage: true },
{ label: "부채율", key: "liabilityRate", isPercentage: true },
{
label: "총마진율",
key: "grossMarginRatio" as NumberKeys,
isPercentage: true,
},
{
label: "사업 수익률",
key: "businessProfitRate" as NumberKeys,
isPercentage: true,
},
{
label: "순이자율",
key: "netInterestRate" as NumberKeys,
isPercentage: true,
},
{ label: "ROE", key: "roeValue" as NumberKeys, isPercentage: true },
{ label: "EPS(주당순이익)", key: "earningsPerShare" as NumberKeys },
{ label: "SPS(주당매출액)", key: "salesPerShare" as NumberKeys },
{ label: "BPS(주당순자산)", key: "bookValuePerShare" as NumberKeys },
{ label: "주식유보율", key: "reserveRate" as NumberKeys, isPercentage: true },
{ label: "부채율", key: "liabilityRate" as NumberKeys, isPercentage: true },
];

const formatValue = (value: number | null, isPercentage?: boolean) => {
Expand Down Expand Up @@ -104,29 +113,40 @@ function NoDataMessage() {
);
}

interface FinancialStatementsProps {
data?: FinancialRatio[];
}
export default function FinancialStatements() {
const { stockName } = useStockInfoContext();

export default function FinancialStatements({
data,
}: FinancialStatementsProps) {
if (!data || data.length === 0) {
const {
data: financialStatements,
isLoading,
isPending,
} = useQuery({
queryKey: ["financialStatements", `${stockName}`],
queryFn: () => getFinancialStatements(stockName),
});

if (!financialStatements || financialStatements.length === 0) {
return <NoDataMessage />;
}

const years = data.map((item) => item.stockAccountingYearMonth);
if (isLoading || isPending) {
return <LoadingSpinner />;
}

const years = financialStatements.map(
(item) => item.stockAccountingYearMonth,
);

return (
<table className="mb-20 w-full">
<table className="mb-40 w-full">
<caption className="mb-10 text-left text-16-500">재무비율</caption>
<TableHeader years={years} />
<tbody className="text-center">
{FINANCIAL_METRICS.map((metric) => (
<TableRow
key={metric.key}
label={metric.label}
values={data.map((item) =>
values={financialStatements.map((item) =>
item[metric.key] !== undefined ? Number(item[metric.key]) : null,
)}
isPercentage={metric.isPercentage}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,49 @@
"use client";

import { useQuery } from "@tanstack/react-query";

import { getConsensus } from "@/api/company-details";
import { useStockInfoContext } from "@/context/stock-info-context";
import { RelativeNews } from "@/types/company-details";
import cleanText from "@/utils/clean-text";

import Consensus from "./consensus";
import FinancialStatements from "./financial-statements";
import TargetPrice from "./target-price";

export default function CompanyOverview() {
interface CompanyOverviewProps {
newsData: RelativeNews[] | undefined;
}

export default function CompanyOverview({ newsData }: CompanyOverviewProps) {
const { stockName } = useStockInfoContext();
const { data: investmentRecommendationData } = useQuery({
queryKey: ["investmentRecommendation", `${stockName}`],
queryFn: () => getConsensus(stockName),
});
return (
<div>
<div className="mb-10 flex justify-between">
<div className="text-16-500">기업정보</div>
<span className="text-14-500 text-gray-100">[기준: 2024. 08. 16]</span>
</div>
<ul className="list-style mb-24 border-y-2 border-solid border-gray-100 py-14 text-14-500">
<li>한국 및 DX 부문 해외 9개</li>
<li>한국 및 DX 부문 해외 9개</li>
<li>한국 및 DX 부문 해외 9개</li>
{newsData
?.map((news) => (
<li key={news.title}>
<a href={news.link} target="_blank">
{cleanText(news.title)}
</a>
</li>
))
.slice(0, 3)}
</ul>

<FinancialStatements />
<Consensus />
<TargetPrice />
<Consensus investmentRecommendationData={investmentRecommendationData} />
<TargetPrice
investmentRecommendationData={investmentRecommendationData}
/>
<div className="text-14-500 text-gray-100">
전 세계 1200개 리서치 회사의 정보를 종합적으로 분석합니다. <br />
기준 2024.08.29 . 레피니티브 제공
Expand Down
Loading
Loading