fix(date): UTC 기반 날짜를 로컬 타임존으로 변경
- 공통 날짜 유틸리티 함수 추가 (src/utils/date.ts)
- getLocalDateString(): 로컬 타임존 YYYY-MM-DD 포맷
- getTodayString(): 오늘 날짜 반환
- getDateAfterDays(): N일 후 날짜 계산
- formatDateForInput(): API 응답 → input 포맷 변환
- toISOString().split('T')[0] 패턴을 공통 함수로 교체
- 견적: QuoteRegistration, QuoteRegistrationV2, types
- 건설: contract, site-briefings, estimates, bidding types
- 건설: IssueDetailForm, ConstructionDetailClient, ProjectEndDialog
- 자재: InspectionCreate, ReceivingReceiptContent, StockStatus/mockData
- 품질: InspectionManagement/mockData
- 기타: PricingFormClient, ShipmentCreate, PurchaseOrderDocument
- 기타: MainDashboard, attendance/actions, dev/generators
문제: toISOString()은 UTC 기준이라 한국(UTC+9)에서 오전 9시 이전에
전날 날짜가 표시되는 버그 발생
해결: 로컬 타임존 기반 날짜 포맷 함수로 통일
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { getTodayString } from "@/utils/date";
|
||||
|
||||
// 필드명 매핑
|
||||
const FIELD_NAME_MAP: Record<string, string> = {
|
||||
@@ -119,7 +120,7 @@ const createNewItem = (): QuoteItem => ({
|
||||
|
||||
// 초기 폼 데이터
|
||||
export const INITIAL_QUOTE_FORM: QuoteFormData = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
registrationDate: getTodayString(),
|
||||
writer: "드미트리", // TODO: 로그인 사용자 정보로 대체
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
|
||||
@@ -48,6 +48,7 @@ import { isNextRedirectError } from "@/lib/utils/redirect-error";
|
||||
import { useDevFill } from "@/components/dev/useDevFill";
|
||||
import type { Vendor } from "../accounting/VendorManagement";
|
||||
import type { BomMaterial, CalculationResults } from "./types";
|
||||
import { getLocalDateString, getDateAfterDays } from "@/utils/date";
|
||||
|
||||
// =============================================================================
|
||||
// 타입 정의
|
||||
@@ -117,7 +118,7 @@ const createNewLocation = (): LocationItem => ({
|
||||
|
||||
// 초기 폼 데이터
|
||||
const INITIAL_FORM_DATA: QuoteFormDataV2 = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
registrationDate: getLocalDateString(new Date()),
|
||||
writer: "", // useAuth()에서 currentUser.name으로 설정됨
|
||||
clientId: "",
|
||||
clientName: "",
|
||||
@@ -251,14 +252,14 @@ export function QuoteRegistrationV2({
|
||||
}
|
||||
|
||||
const testData: QuoteFormDataV2 = {
|
||||
registrationDate: new Date().toISOString().split("T")[0],
|
||||
registrationDate: getLocalDateString(new Date()),
|
||||
writer: writerName,
|
||||
clientId: clients[0]?.id?.toString() || "",
|
||||
clientName: clients[0]?.company_name || "테스트 거래처",
|
||||
siteName: "테스트 현장",
|
||||
manager: "홍길동",
|
||||
contact: "010-1234-5678",
|
||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
|
||||
dueDate: getDateAfterDays(7),
|
||||
remarks: "[DevFill] 테스트 견적입니다.",
|
||||
status: "draft",
|
||||
locations: testLocations,
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* - product_category: screen, steel
|
||||
*/
|
||||
|
||||
import { formatDateForInput } from "@/utils/date";
|
||||
|
||||
// ===== 견적 상태 =====
|
||||
export type QuoteStatus =
|
||||
| 'draft' // 작성중
|
||||
@@ -33,29 +35,6 @@ export const QUOTE_STATUS_COLORS: Record<QuoteStatus, string> = {
|
||||
converted: 'bg-indigo-100 text-indigo-800',
|
||||
};
|
||||
|
||||
// ===== 날짜 형식 변환 헬퍼 =====
|
||||
/**
|
||||
* API 날짜 문자열을 HTML date input용 YYYY-MM-DD 형식으로 변환
|
||||
* 지원 형식: ISO 8601, datetime string, date only
|
||||
*/
|
||||
function formatDateForInput(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인 경우
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// ISO 8601 또는 datetime 형식 (2025-01-06T00:00:00.000Z, 2025-01-06 00:00:00)
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return ''; // 유효하지 않은 날짜
|
||||
}
|
||||
|
||||
// YYYY-MM-DD 형식으로 변환
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// ===== 제품 카테고리 =====
|
||||
export type ProductCategory = 'screen' | 'steel';
|
||||
|
||||
@@ -717,7 +696,12 @@ export function transformV2ToApi(
|
||||
): Record<string, unknown> {
|
||||
|
||||
// 1. calculation_inputs 생성 (폼 복원용)
|
||||
const calculationInputs: CalculationInputs = {
|
||||
// bomResults 수집 (인자로 받은 것 또는 locations에 저장된 것)
|
||||
const collectedBomResults = bomResults || data.locations
|
||||
.map(loc => loc.bomResult)
|
||||
.filter((br): br is BomCalculationResult => br !== undefined);
|
||||
|
||||
const calculationInputs: CalculationInputs & { bomResults?: BomCalculationResult[] } = {
|
||||
items: data.locations.map(loc => ({
|
||||
productCategory: 'screen', // TODO: 동적으로 결정
|
||||
productName: loc.productName,
|
||||
@@ -732,6 +716,8 @@ export function transformV2ToApi(
|
||||
code: loc.code,
|
||||
quantity: loc.quantity,
|
||||
})),
|
||||
// BOM 결과 저장 (조회 시 복원용)
|
||||
bomResults: collectedBomResults.length > 0 ? collectedBomResults : undefined,
|
||||
};
|
||||
|
||||
// 2. items 생성 (BOM 결과 있으면 자재 상세, 없으면 완제품 기준)
|
||||
@@ -867,7 +853,15 @@ export function transformV2ToApi(
|
||||
* - 없으면 items에서 추출 시도 (레거시 호환)
|
||||
*/
|
||||
export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
const calcInputs = apiData.calculation_inputs?.items || [];
|
||||
// 원본 API 데이터(calculation_inputs)와 변환된 데이터(calculationInputs) 모두 지원
|
||||
// getQuoteById가 transformApiToFrontend를 적용하면 calculationInputs(camelCase)가 됨
|
||||
const rawCalcInputs = apiData.calculation_inputs;
|
||||
const transformedCalcInputs = (apiData as unknown as { calculationInputs?: CalculationInputs & { bomResults?: BomCalculationResult[] } }).calculationInputs;
|
||||
const calculationInputs = rawCalcInputs || transformedCalcInputs;
|
||||
|
||||
const calcInputs = calculationInputs?.items || [];
|
||||
// BOM 결과 복원 (저장 시 calculation_inputs.bomResults에 저장됨)
|
||||
const savedBomResults = (calculationInputs as { bomResults?: BomCalculationResult[] } | undefined)?.bomResults || [];
|
||||
|
||||
// calculation_inputs에서 locations 복원
|
||||
let locations: LocationItem[] = [];
|
||||
@@ -884,6 +878,9 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
);
|
||||
const qty = ci.quantity || 1;
|
||||
|
||||
// 해당 인덱스의 BOM 결과 복원
|
||||
const bomResult = savedBomResults[index];
|
||||
|
||||
return {
|
||||
id: `loc-${index}`,
|
||||
floor: ci.floor || '',
|
||||
@@ -900,6 +897,7 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
inspectionFee: ci.inspectionFee || 50000,
|
||||
unitPrice: totalPrice > 0 ? Math.round(totalPrice / qty) : undefined,
|
||||
totalPrice: totalPrice > 0 ? totalPrice : undefined,
|
||||
bomResult: bomResult, // BOM 결과 복원
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -910,43 +908,44 @@ export function transformApiToV2(apiData: QuoteApiData): QuoteFormDataV2 {
|
||||
return 'draft';
|
||||
};
|
||||
|
||||
// 변환된 Quote 데이터 타입 (camelCase)
|
||||
const transformed = apiData as unknown as {
|
||||
registrationDate?: string;
|
||||
createdBy?: string;
|
||||
clientId?: string;
|
||||
clientName?: string;
|
||||
siteName?: string;
|
||||
managerName?: string;
|
||||
managerContact?: string;
|
||||
deliveryDate?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
registrationDate: formatDateForInput(apiData.registration_date),
|
||||
writer: apiData.creator?.name || '',
|
||||
clientId: apiData.client_id ? String(apiData.client_id) : '',
|
||||
clientName: apiData.client?.name || apiData.client_name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
manager: apiData.manager || apiData.manager_name || '',
|
||||
contact: apiData.contact || apiData.manager_contact || '',
|
||||
dueDate: formatDateForInput(apiData.completion_date || apiData.delivery_date),
|
||||
remarks: apiData.remarks || apiData.description || '',
|
||||
// raw API: registration_date, transformed: registrationDate
|
||||
registrationDate: formatDateForInput(apiData.registration_date || transformed.registrationDate),
|
||||
// raw API: creator?.name, transformed: createdBy
|
||||
writer: apiData.creator?.name || transformed.createdBy || '',
|
||||
// raw API: client_id, transformed: clientId
|
||||
clientId: apiData.client_id ? String(apiData.client_id) : (transformed.clientId || ''),
|
||||
// raw API: client?.name || client_name, transformed: clientName
|
||||
clientName: apiData.client?.name || apiData.client_name || transformed.clientName || '',
|
||||
// raw API: site_name, transformed: siteName
|
||||
siteName: apiData.site_name || transformed.siteName || '',
|
||||
// raw API: manager || manager_name, transformed: managerName
|
||||
manager: apiData.manager || apiData.manager_name || transformed.managerName || '',
|
||||
// raw API: contact || manager_contact, transformed: managerContact
|
||||
contact: apiData.contact || apiData.manager_contact || transformed.managerContact || '',
|
||||
// raw API: completion_date || delivery_date, transformed: deliveryDate
|
||||
dueDate: formatDateForInput(apiData.completion_date || apiData.delivery_date || transformed.deliveryDate),
|
||||
// raw API: remarks || description, transformed: description
|
||||
remarks: apiData.remarks || apiData.description || transformed.description || '',
|
||||
status: mapStatus(apiData.status),
|
||||
locations: locations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 형식 변환 헬퍼 (V2용 - formatDateForInput과 동일)
|
||||
* API 날짜 문자열을 HTML date input용 YYYY-MM-DD 형식으로 변환
|
||||
*/
|
||||
function formatDateForInputV2(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
|
||||
// 이미 YYYY-MM-DD 형식인 경우
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// ISO 8601 또는 datetime 형식
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// ===== QuoteFormData → API 요청 데이터 변환 =====
|
||||
export function transformFormDataToApi(formData: QuoteFormData): Record<string, unknown> {
|
||||
// calculationResults가 있으면 BOM 자재 기반으로 items 생성
|
||||
|
||||
Reference in New Issue
Block a user