게시판 등록시간 수정
This commit is contained in:
+5
-1
@@ -28,8 +28,12 @@ RUN mvn -f backend/pom.xml package -DskipTests -q
|
|||||||
FROM eclipse-temurin:21-jre-alpine AS production
|
FROM eclipse-temurin:21-jre-alpine AS production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV TZ=Asia/Seoul
|
||||||
|
|
||||||
RUN addgroup -S spring && adduser -S spring -G spring \
|
RUN addgroup -S spring && adduser -S spring -G spring \
|
||||||
&& apk add --no-cache su-exec
|
&& apk add --no-cache su-exec tzdata \
|
||||||
|
&& ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||||
|
&& echo $TZ > /etc/timezone
|
||||||
|
|
||||||
COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar
|
COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar
|
||||||
COPY backend/docker-entrypoint.sh /docker-entrypoint.sh
|
COPY backend/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.goldenchart.entity;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import com.goldenchart.util.AppTime;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -42,12 +44,12 @@ public class GcVerificationIssue {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = AppTime.nowLocal();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = AppTime.nowLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = AppTime.nowLocal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.goldenchart.entity;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import com.goldenchart.util.AppTime;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -37,12 +39,12 @@ public class GcVerificationIssueComment {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = AppTime.nowLocal();
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = AppTime.nowLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
protected void onUpdate() {
|
protected void onUpdate() {
|
||||||
updatedAt = LocalDateTime.now();
|
updatedAt = AppTime.nowLocal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.goldenchart.entity;
|
|||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
|
||||||
|
import com.goldenchart.util.AppTime;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -38,6 +40,6 @@ public class GcVerificationIssueImage {
|
|||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
createdAt = LocalDateTime.now();
|
createdAt = AppTime.nowLocal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.goldenchart.util;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
|
||||||
|
/** 앱 기본 시간대(한국) — LocalDateTime 저장·표시용 */
|
||||||
|
public final class AppTime {
|
||||||
|
|
||||||
|
public static final ZoneId ZONE = ZoneId.of("Asia/Seoul");
|
||||||
|
|
||||||
|
private AppTime() {}
|
||||||
|
|
||||||
|
public static LocalDateTime nowLocal() {
|
||||||
|
return LocalDateTime.now(ZONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-8
@@ -1653,10 +1653,13 @@ function App() {
|
|||||||
const lastKnownBar = useUpbit
|
const lastKnownBar = useUpbit
|
||||||
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
||||||
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
||||||
const currentBar = legend ?? lastKnownBar;
|
/** 크로스헤어 OHLC·지표 범례 */
|
||||||
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
|
const hoverBar = legend ?? lastKnownBar;
|
||||||
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
|
/** 종목명 옆 시세 — 실시간 현재가만 */
|
||||||
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
|
const quoteBar = lastKnownBar;
|
||||||
|
const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true;
|
||||||
|
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0;
|
||||||
|
const dailyChange = quoteBar ? quoteBar.close - prevClose : 0;
|
||||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
||||||
|
|
||||||
const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]);
|
const openNotificationsPage = useCallback(() => guardedSetMenuPage('notifications'), [guardedSetMenuPage]);
|
||||||
@@ -2071,10 +2074,10 @@ function App() {
|
|||||||
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
|
<span className="upbit-market">{getKoreanName(symbol)} <span style={{opacity:0.5, fontSize:'11px'}}>{symbol}</span></span>
|
||||||
{isLoading && <span className="upbit-loading">데이터 로딩 중...</span>}
|
{isLoading && <span className="upbit-loading">데이터 로딩 중...</span>}
|
||||||
{error && <span className="upbit-error">⚠ {error}</span>}
|
{error && <span className="upbit-error">⚠ {error}</span>}
|
||||||
{!isLoading && !error && currentBar && (
|
{!isLoading && !error && quoteBar && (
|
||||||
<>
|
<>
|
||||||
<span className="upbit-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
<span className="upbit-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||||
{currentBar.close.toLocaleString()} KRW
|
{quoteBar.close.toLocaleString()} KRW
|
||||||
</span>
|
</span>
|
||||||
<span className={`upbit-change ${isUp ? 'up' : 'down'}`}>
|
<span className={`upbit-change ${isUp ? 'up' : 'down'}`}>
|
||||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
||||||
@@ -2249,7 +2252,8 @@ function App() {
|
|||||||
useUpbit={useUpbit}
|
useUpbit={useUpbit}
|
||||||
wsStatus={wsStatus}
|
wsStatus={wsStatus}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
currentBar={currentBar}
|
currentBar={hoverBar}
|
||||||
|
quoteBar={quoteBar}
|
||||||
isUp={isUp}
|
isUp={isUp}
|
||||||
dailyChange={dailyChange}
|
dailyChange={dailyChange}
|
||||||
dailyChangePct={dailyChangePct}
|
dailyChangePct={dailyChangePct}
|
||||||
@@ -2622,7 +2626,7 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
{showAlert && (
|
{showAlert && (
|
||||||
<AlertManager
|
<AlertManager
|
||||||
alerts={alerts} currentPrice={currentBar?.close ?? 0}
|
alerts={alerts} currentPrice={quoteBar?.close ?? 0}
|
||||||
onAdd={(price, label) => { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }}
|
onAdd={(price, label) => { managerRef.current?.addAlert(price, label); setAlerts(prev => [...prev, { price, label }]); }}
|
||||||
onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }}
|
onRemove={price => { managerRef.current?.removeAlert(price); setAlerts(prev => prev.filter(a => Math.abs(a.price - price) > 0.0001)); }}
|
||||||
onClose={() => setShowAlert(false)}
|
onClose={() => setShowAlert(false)}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export interface ChartLegendBarProps {
|
|||||||
useUpbit: boolean;
|
useUpbit: boolean;
|
||||||
wsStatus: WsStatus;
|
wsStatus: WsStatus;
|
||||||
mode: ChartMode;
|
mode: ChartMode;
|
||||||
|
/** 크로스헤어·OHLC 범례용 (없으면 quoteBar) */
|
||||||
currentBar: LegendData | null;
|
currentBar: LegendData | null;
|
||||||
|
/** 종목명 옆 시세·등락 — 실시간 현재가만 (크로스헤어 미반영) */
|
||||||
|
quoteBar?: LegendData | null;
|
||||||
isUp: boolean;
|
isUp: boolean;
|
||||||
dailyChange: number;
|
dailyChange: number;
|
||||||
dailyChangePct: number;
|
dailyChangePct: number;
|
||||||
@@ -44,13 +47,16 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
|||||||
wsStatus,
|
wsStatus,
|
||||||
mode,
|
mode,
|
||||||
currentBar,
|
currentBar,
|
||||||
|
quoteBar: quoteBarProp,
|
||||||
isUp,
|
isUp,
|
||||||
dailyChange,
|
dailyChange,
|
||||||
dailyChangePct,
|
dailyChangePct,
|
||||||
indicators,
|
indicators,
|
||||||
legend,
|
legend,
|
||||||
}) => {
|
}) => {
|
||||||
const showOhlc = currentBar && (v.open || v.high || v.low || v.close || v.change);
|
const ohlcBar = currentBar;
|
||||||
|
const quoteBar = quoteBarProp ?? currentBar;
|
||||||
|
const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close || v.change);
|
||||||
const showAny =
|
const showAny =
|
||||||
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|
||||||
|| showOhlc || (v.indicators && indicators.length > 0);
|
|| showOhlc || (v.indicators && indicators.length > 0);
|
||||||
@@ -70,17 +76,17 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
|||||||
{mode === 'trading' && v.tradingBadge && <span className="tv-legend-badge trading">✏</span>}
|
{mode === 'trading' && v.tradingBadge && <span className="tv-legend-badge trading">✏</span>}
|
||||||
{showOhlc && (
|
{showOhlc && (
|
||||||
<span className="tv-legend-ohlcv">
|
<span className="tv-legend-ohlcv">
|
||||||
{v.open && (
|
{v.open && ohlcBar && (
|
||||||
<span>O <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.open)}</b></span>
|
<span>O <b className={isUp ? 'up' : 'down'}>{formatPrice(ohlcBar.open)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.high && (
|
{v.high && ohlcBar && (
|
||||||
<span>H <b className="up">{formatPrice(currentBar!.high)}</b></span>
|
<span>H <b className="up">{formatPrice(ohlcBar.high)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.low && (
|
{v.low && ohlcBar && (
|
||||||
<span>L <b className="down">{formatPrice(currentBar!.low)}</b></span>
|
<span>L <b className="down">{formatPrice(ohlcBar.low)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.close && (
|
{v.close && quoteBar && (
|
||||||
<span>C <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.close)}</b></span>
|
<span>C <b className={isUp ? 'up' : 'down'}>{formatPrice(quoteBar.close)}</b></span>
|
||||||
)}
|
)}
|
||||||
{v.change && (
|
{v.change && (
|
||||||
<span className={`tv-legend-chg ${isUp ? 'up' : 'down'}`}>
|
<span className={`tv-legend-chg ${isUp ? 'up' : 'down'}`}>
|
||||||
|
|||||||
@@ -556,10 +556,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
const lastKnownBar = useUpbit
|
const lastKnownBar = useUpbit
|
||||||
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
||||||
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
||||||
const currentBar = legend ?? lastKnownBar;
|
const quoteBar = lastKnownBar;
|
||||||
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
|
const isUp = quoteBar ? quoteBar.close >= quoteBar.open : true;
|
||||||
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
|
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : quoteBar?.open ?? 0;
|
||||||
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
|
const dailyChange = quoteBar ? quoteBar.close - prevClose : 0;
|
||||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
||||||
|
|
||||||
const receiveSignal = useMemo(() => {
|
const receiveSignal = useMemo(() => {
|
||||||
@@ -671,8 +671,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, [compactMode, onActivate, showMarket]);
|
}, [compactMode, onActivate, showMarket]);
|
||||||
|
|
||||||
const displayKoName = compactKoName(symbol);
|
const displayKoName = compactKoName(symbol);
|
||||||
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
|
const trendUp = compactMode && quoteBar != null && dailyChangePct >= 0;
|
||||||
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
|
const trendDown = compactMode && quoteBar != null && dailyChangePct < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -708,13 +708,13 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
<span className="slot-compact-name-text">{displayKoName}</span>
|
<span className="slot-compact-name-text">{displayKoName}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{currentBar && (
|
{quoteBar && (
|
||||||
<div className="slot-compact-quote" aria-label="현재가">
|
<div className="slot-compact-quote" aria-label="현재가">
|
||||||
<span
|
<span
|
||||||
className="slot-compact-price"
|
className="slot-compact-price"
|
||||||
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}
|
||||||
>
|
>
|
||||||
{formatPrice(currentBar.close)}
|
{formatPrice(quoteBar.close)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="slot-compact-change"
|
className="slot-compact-change"
|
||||||
@@ -772,9 +772,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{currentBar && (
|
{quoteBar && (
|
||||||
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||||
{formatPrice(currentBar.close)}
|
{formatPrice(quoteBar.close)}
|
||||||
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -586,7 +586,7 @@ const GeneralPanel: React.FC<{
|
|||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="날짜·시간 형식"
|
label="차트 날짜, 시간형식"
|
||||||
desc="캔들·보조지표 차트 하단 시간축·크로스헤어에 적용됩니다. 프리셋 선택 또는 직접 입력(토큰: yyyy, yy, MM, dd, HH, mm, ss)이 가능합니다."
|
desc="캔들·보조지표 차트 하단 시간축·크로스헤어에 적용됩니다. 프리셋 선택 또는 직접 입력(토큰: yyyy, yy, MM, dd, HH, mm, ss)이 가능합니다."
|
||||||
>
|
>
|
||||||
<ChartTimeFormatPicker
|
<ChartTimeFormatPicker
|
||||||
|
|||||||
@@ -10,20 +10,12 @@ import {
|
|||||||
updateVerificationIssueComment,
|
updateVerificationIssueComment,
|
||||||
type VerificationIssueCommentDto,
|
type VerificationIssueCommentDto,
|
||||||
} from '../../utils/verificationBoardApi';
|
} from '../../utils/verificationBoardApi';
|
||||||
|
import { formatIsoDateTime } from '../../utils/timezone';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issueId: number;
|
issueId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(iso?: string): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString('ko-KR', {
|
|
||||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultAuthorName(): string {
|
function defaultAuthorName(): string {
|
||||||
const session = getAuthSession();
|
const session = getAuthSession();
|
||||||
if (session?.displayName) return session.displayName;
|
if (session?.displayName) return session.displayName;
|
||||||
@@ -142,7 +134,7 @@ const VerificationIssueCommentsSection: React.FC<Props> = ({ issueId }) => {
|
|||||||
<div className="vbd-comment-head">
|
<div className="vbd-comment-head">
|
||||||
<span className="vbd-comment-author">{c.authorName ?? '익명'}</span>
|
<span className="vbd-comment-author">{c.authorName ?? '익명'}</span>
|
||||||
<span className="vbd-comment-date">
|
<span className="vbd-comment-date">
|
||||||
{formatDateTime(c.updatedAt ?? c.createdAt)}
|
{formatIsoDateTime(c.updatedAt ?? c.createdAt, undefined, 'short')}
|
||||||
{c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'}
|
{c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'}
|
||||||
</span>
|
</span>
|
||||||
<div className="vbd-comment-actions">
|
<div className="vbd-comment-actions">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import ReproductionPathInput from './ReproductionPathInput';
|
import ReproductionPathInput from './ReproductionPathInput';
|
||||||
import VerificationIssueCommentsSection from './VerificationIssueCommentsSection';
|
import VerificationIssueCommentsSection from './VerificationIssueCommentsSection';
|
||||||
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
|
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
|
||||||
|
import { formatIsoDateTime } from '../../utils/timezone';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issue: VerificationIssueDto | null;
|
issue: VerificationIssueDto | null;
|
||||||
@@ -23,13 +24,6 @@ interface Props {
|
|||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(iso?: string): string {
|
|
||||||
if (!iso) return '—';
|
|
||||||
try {
|
|
||||||
return new Date(iso).toLocaleString('ko-KR');
|
|
||||||
} catch { return iso; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const VerificationIssueDetailPanel: React.FC<Props> = ({
|
const VerificationIssueDetailPanel: React.FC<Props> = ({
|
||||||
issue,
|
issue,
|
||||||
attachmentsRefreshKey = 0,
|
attachmentsRefreshKey = 0,
|
||||||
@@ -137,8 +131,8 @@ const VerificationIssueDetailPanel: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vbd-detail-meta">
|
<div className="vbd-detail-meta">
|
||||||
<span>등록 {formatDateTime(issue.createdAt)}</span>
|
<span>등록 {formatIsoDateTime(issue.createdAt)}</span>
|
||||||
<span>수정 {formatDateTime(issue.updatedAt)}</span>
|
<span>수정 {formatIsoDateTime(issue.updatedAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="vbd-field">
|
<label className="vbd-field">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type VerificationIssueStage,
|
type VerificationIssueStage,
|
||||||
} from '../../utils/verificationBoardStages';
|
} from '../../utils/verificationBoardStages';
|
||||||
import VerificationStageIcon from './VerificationStageIcon';
|
import VerificationStageIcon from './VerificationStageIcon';
|
||||||
|
import { formatIsoDateTime } from '../../utils/timezone';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issues: VerificationIssueDto[];
|
issues: VerificationIssueDto[];
|
||||||
@@ -21,16 +22,6 @@ interface Props {
|
|||||||
onSelect: (issue: VerificationIssueDto) => void;
|
onSelect: (issue: VerificationIssueDto) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso?: string): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
try {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString('ko-KR', {
|
|
||||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
} catch { return ''; }
|
|
||||||
}
|
|
||||||
|
|
||||||
const VerificationIssueListPanel: React.FC<Props> = ({
|
const VerificationIssueListPanel: React.FC<Props> = ({
|
||||||
issues,
|
issues,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -102,7 +93,7 @@ const VerificationIssueListPanel: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{stageLabel(issue.stage)}
|
{stageLabel(issue.stage)}
|
||||||
</span>
|
</span>
|
||||||
<span className="vbd-list-meta">{formatDate(issue.updatedAt ?? issue.createdAt)}</span>
|
<span className="vbd-list-meta">{formatIsoDateTime(issue.updatedAt ?? issue.createdAt, undefined, 'short')}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="vbd-list-title">{issue.title}</span>
|
<span className="vbd-list-title">{issue.title}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -155,3 +155,53 @@ export function formatNowClock(tz?: string, withSeconds = true): string {
|
|||||||
const zone = normalizeTimezone(tz ?? _tz);
|
const zone = normalizeTimezone(tz ?? _tz);
|
||||||
return formatUnixClock(Math.floor(Date.now() / 1000), zone, withSeconds);
|
return formatUnixClock(Math.floor(Date.now() / 1000), zone, withSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 백엔드 LocalDateTime(JSON, 타임존 없음) 저장 기준 — Asia/Seoul 벽시계 */
|
||||||
|
export const BACKEND_NAIVE_TIMEZONE = 'Asia/Seoul';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 백엔드가 내려준 ISO 문자열(타임존 없음)을 Instant로 변환.
|
||||||
|
* Docker(UTC JVM)에서 저장된 과거 UTC 벽시계 값은 +00:00, 신규는 +09:00으로 해석.
|
||||||
|
*/
|
||||||
|
export function parseBackendNaiveDateTime(iso: string): Date | null {
|
||||||
|
const raw = (iso ?? '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
if (/[Zz]$/.test(raw) || /[+-]\d{2}:?\d{2}$/.test(raw)) {
|
||||||
|
const d = new Date(raw);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T');
|
||||||
|
const withSec = /:\d{2}/.test(normalized.slice(11)) ? normalized : `${normalized}:00`;
|
||||||
|
const d = new Date(`${withSec}+09:00`);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
const fallback = new Date(raw);
|
||||||
|
return Number.isNaN(fallback.getTime()) ? null : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 검증게시판·API LocalDateTime → 표시 시간대(기본 앱 설정) */
|
||||||
|
export function formatIsoDateTime(
|
||||||
|
iso?: string | null,
|
||||||
|
tz?: string,
|
||||||
|
style: 'full' | 'short' = 'full',
|
||||||
|
): string {
|
||||||
|
if (!iso) return style === 'full' ? '—' : '';
|
||||||
|
const d = parseBackendNaiveDateTime(iso);
|
||||||
|
if (!d) return iso;
|
||||||
|
const zone = normalizeTimezone(tz ?? _tz);
|
||||||
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
|
timeZone: zone,
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
if (style === 'full') {
|
||||||
|
opts.year = 'numeric';
|
||||||
|
opts.month = '2-digit';
|
||||||
|
opts.day = '2-digit';
|
||||||
|
opts.second = '2-digit';
|
||||||
|
} else {
|
||||||
|
opts.month = '2-digit';
|
||||||
|
opts.day = '2-digit';
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat('ko-KR', opts).format(d);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user