fix: 견적관리 공과 품목 API 파라미터 및 MES 기능 개선

- 공과 상세 셀렉트 박스 API 파라미터 수정 (type → item_type)
- VendorManagement 목록 컴포넌트 개선
- 작업지시/작업실적 타입 및 UI 개선
- 검사관리 actions 수정
This commit is contained in:
2026-01-15 08:52:40 +09:00
parent 6dc91daaca
commit 0f8f40fc7b
25 changed files with 720 additions and 77 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.serena/.DS_Store vendored Normal file

Binary file not shown.

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

View File

@@ -0,0 +1,81 @@
# 견적 등록/수정 FormField type="custom" 수정 작업
## 📅 작업일: 2026-01-06
## 🎯 문제 요약
견적 등록/수정 페이지에서 **수량(quantity) 변경이 총합계에 반영되지 않는 버그**
### 증상
- 수량 1 → 자동견적산출 → 합계 1,711,225원
- 수량 3으로 변경 → 자동견적산출 → 합계가 여전히 1,711,225원 (3배인 ~5,133,675원이어야 함)
## 🔍 근본 원인
**FormField 컴포넌트의 `type="custom"` 누락**
FormField 컴포넌트 (`src/components/molecules/FormField.tsx`)는 `type` prop에 따라 다르게 동작:
- `type="custom"` → children(자식 요소)을 렌더링
- 그 외 → 자체 내부 Input을 렌더링 (value/onChange 연결 안됨)
```tsx
// FormField.tsx 내부 renderInput() 함수
case 'custom':
return children; // ← children 렌더링
default:
return <Input value={value} onChange={...} /> // ← 자체 Input 렌더링 (value=undefined)
```
**결과**: `type="custom"` 없이 FormField 안에 Input을 넣으면, 해당 Input은 렌더링되지 않고 FormField 자체 Input이 렌더링됨 → state와 연결 끊김
## ✅ 수정 완료 (8개 FormField)
### 파일: `src/components/quotes/QuoteRegistration.tsx`
**기본정보 섹션** (3개):
1. 등록일 (line 581) - `type="custom"` 추가
2. 현장명 (line 627) - `type="custom"` 추가 (datalist 자동완성 포함)
3. 납기일 (line 662) - `type="custom"` 추가
**견적 항목 섹션** (5개):
4. 층수 (line 733) - `type="custom"` 추가
5. 부호 (line 744) - `type="custom"` 추가
6. **수량 (line 926)** - `type="custom"` 추가 ⭐ 핵심 버그 원인
7. 마구리 날개치수 (line 944) - `type="custom"` 추가
8. 검사비 (line 959) - `type="custom"` 추가
## 추가 수정사항
### 1. useMemo로 calculatedGrandTotal 추가 (line 194-201)
```tsx
const calculatedGrandTotal = useMemo(() => {
if (!calculationResults?.items) return 0;
return calculationResults.items.reduce((sum, itemResult) => {
const formItem = formData.items[itemResult.index];
return sum + (itemResult.result.grand_total * (formItem?.quantity || 1));
}, 0);
}, [calculationResults, formData.items]);
```
### 2. Toast 메시지 수정 (line 493-498)
`updatedItems` 사용하여 최신 상태 반영
### 3. Badge 및 하단 총합계
`calculatedGrandTotal` 사용 (line 1019, 1131)
## ⚠️ 남은 이슈
사용자가 "견적 산출 결과에는 왜 반영이 안되는거지?"라고 질문 → 확인 필요:
1. 수정된 코드로 테스트했는지 (브라우저 새로고침)
2. 수량 변경 후 즉시 반영되는지 vs 버튼 클릭 필요한지
3. 현재 코드에서 수량 변경 시 합계는 실시간 업데이트되어야 함 (useMemo + React 재렌더링)
## 📁 관련 파일
- `src/components/quotes/QuoteRegistration.tsx` - 메인 수정 파일
- `src/components/molecules/FormField.tsx` - FormField 컴포넌트 (참조)
- `src/app/[locale]/(protected)/sales/quote-management/[id]/edit/page.tsx` - 수정 페이지
## 🔑 핵심 교훈
**FormField에 커스텀 children(Input, Select, datalist 등)을 넣을 때는 반드시 `type="custom"` 필요!**
## 🚀 새 세션에서 이어서 작업하려면
1. 프로젝트 활성화: Serena `activate_project` → "react"
2. 메모리 읽기: `read_memory("quote-registration-formfield-fix.md")`
3. 확인 필요: 수량 변경 시 견적 산출 결과가 실시간으로 업데이트되는지 테스트

View File

@@ -0,0 +1,70 @@
# 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
## 작업 일시
2026-01-02
## 문제 상황
"최근 1년" 필터가 제대로 동작하지 않는 3가지 버그:
1. 2026년 조회 후 "최근 1년" 선택 시 2026년 기준 데이터 표시
2. 2025년 조회 후 "최근 1년" 선택 시 2025년 기준 데이터 표시
3. 초기 페이지 로드 시 "최근 1년" 기본값인데 데이터 없음
## 원인 분석
### 프론트엔드 (이전 세션에서 수정됨)
- JavaScript에서 `year === 0` 체크가 falsy 값 문제로 제대로 동작하지 않음
- `if (year)` 같은 조건문에서 0이 false로 처리됨
### 백엔드 (이번 세션에서 수정)
- Laravel의 `'nullable|boolean'` 검증이 쿼리 파라미터로 전달된 문자열 "true"를 거부
- HTTP 쿼리 파라미터는 항상 문자열로 전달됨
## 수정 내용
### 1. ReceivablesController.php
```php
// 변경 전
'recent_year' => 'nullable|boolean',
// 변경 후
'recent_year' => 'nullable|string|in:true,false,1,0',
// 검증 후 boolean 변환
if (isset($params['recent_year'])) {
$params['recent_year'] = filter_var($params['recent_year'], FILTER_VALIDATE_BOOLEAN);
}
\Log::info('[Receivables] index params', $params);
```
### 2. actions.ts (이전 세션 수정, 검증됨)
```typescript
const yearValue = params?.year;
if (typeof yearValue === 'number') {
if (yearValue === 0) {
searchParams.set('recent_year', 'true');
} else {
searchParams.set('year', String(yearValue));
}
}
```
## 핵심 포인트
1. **명시적 타입 체크**: `typeof yearValue === 'number'`로 undefined와 0을 구분
2. **문자열 boolean 검증**: Laravel에서 `'in:true,false,1,0'` 사용 후 `filter_var()` 변환
3. **디버그 로깅**: 개발 중 파라미터 확인을 위한 로그 추가 (테스트 후 제거 필요)
## Git 커밋
- API: `4fa38e3` - feat(API): 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
- React: `672b1b4` - feat(WEB): 채권현황 동적월 지원 및 year=0 파라미터 버그 수정
- React: `1f32b04` - docs: 채권현황 동적월 지원 작업 현황 업데이트
## 관련 파일
- `/api/app/Http/Controllers/Api/V1/ReceivablesController.php`
- `/api/app/Services/ReceivablesService.php`
- `/react/src/components/accounting/ReceivablesStatus/actions.ts`
- `/react/src/components/accounting/ReceivablesStatus/index.tsx`
## 후속 작업
- [ ] 테스트 완료 후 디버그 로그 제거
- [ ] 추가 UI 개선 (사용자 확인 필요)

84
.serena/project.yml Normal file
View File

@@ -0,0 +1,84 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts yaml zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "react"
included_optional_tools: []

BIN
claudedocs/.DS_Store vendored

Binary file not shown.

View File

@@ -2,7 +2,7 @@
> **작성일**: 2025-12-04
> **목적**: 거래처관리 페이지 API 연동 및 sam-design 기준 UI 구현
> **최종 업데이트**: 2025-12-04 ✅ 구현 완료
> **최종 업데이트**: 2025-12-04 ✅ 구현 완료
---

158
deploy.sh Executable file
View File

@@ -0,0 +1,158 @@
#!/bin/bash
#
# SAM React 배포 스크립트
# 사용법: ./deploy.sh [dev|prod]
#
set -e # 에러 발생 시 중단
# ===========================================
# 설정
# ===========================================
ENV="${1:-dev}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BUILD_FILE="next-build.tar.gz"
# 개발 서버 설정
DEV_SSH="hskwon@114.203.209.83"
DEV_PATH="/home/webservice/react"
DEV_PM2="sam-react"
# 운영 서버 설정 (추후 설정)
# PROD_SSH="user@prod-server"
# PROD_PATH="/var/www/react"
# PROD_PM2="sam-react-prod"
# 환경별 설정 선택
case $ENV in
dev)
SSH_TARGET=$DEV_SSH
REMOTE_PATH=$DEV_PATH
PM2_APP=$DEV_PM2
;;
prod)
echo "❌ 운영 환경은 아직 설정되지 않았습니다."
exit 1
;;
*)
echo "❌ 알 수 없는 환경: $ENV"
echo "사용법: ./deploy.sh [dev|prod]"
exit 1
;;
esac
# ===========================================
# 함수 정의
# ===========================================
log() {
echo ""
echo "=========================================="
echo "🚀 $1"
echo "=========================================="
}
error() {
echo ""
echo "❌ 에러: $1"
exit 1
}
# ===========================================
# 1. 빌드
# ===========================================
log "Step 1/5: 빌드 시작"
# .env.local 백업
if [ -f .env.local ]; then
echo "📦 .env.local 백업..."
mv .env.local .env.local.bak
fi
# 빌드 실행
echo "🔨 npm run build..."
npm run build || {
# 빌드 실패 시 .env.local 복원
if [ -f .env.local.bak ]; then
mv .env.local.bak .env.local
fi
error "빌드 실패"
}
# .env.local 복원
if [ -f .env.local.bak ]; then
echo "📦 .env.local 복원..."
mv .env.local.bak .env.local
fi
echo "✅ 빌드 완료"
# ===========================================
# 2. 압축
# ===========================================
log "Step 2/5: 압축 시작"
# 기존 압축 파일 삭제
rm -f $BUILD_FILE
# .next 폴더 압축 (캐시 제외)
echo "📦 .next 폴더 압축 중..."
COPYFILE_DISABLE=1 tar --exclude='.next/cache' -czf $BUILD_FILE .next
# 파일 크기 확인
FILE_SIZE=$(ls -lh $BUILD_FILE | awk '{print $5}')
echo "✅ 압축 완료: $BUILD_FILE ($FILE_SIZE)"
# ===========================================
# 3. 업로드
# ===========================================
log "Step 3/5: 서버 업로드"
echo "📤 $SSH_TARGET:$REMOTE_PATH 로 업로드 중..."
scp $BUILD_FILE $SSH_TARGET:$REMOTE_PATH/
echo "✅ 업로드 완료"
# ===========================================
# 4. 원격 배포 실행
# ===========================================
log "Step 4/5: 원격 배포 실행"
echo "🔧 서버에서 배포 스크립트 실행 중..."
ssh $SSH_TARGET << EOF
cd $REMOTE_PATH
echo "🗑️ 기존 .next 폴더 삭제..."
rm -rf .next
echo "📦 압축 해제 중..."
tar xzf $BUILD_FILE
echo "🔄 PM2 재시작..."
pm2 restart $PM2_APP
echo "🧹 압축 파일 정리..."
rm -f $BUILD_FILE
echo "✅ 서버 배포 완료"
EOF
# ===========================================
# 5. 정리
# ===========================================
log "Step 5/5: 로컬 정리"
echo "🧹 로컬 압축 파일 삭제..."
rm -f $BUILD_FILE
# ===========================================
# 완료
# ===========================================
echo ""
echo "=========================================="
echo "🎉 배포 완료!"
echo "=========================================="
echo "환경: $ENV"
echo "서버: $SSH_TARGET"
echo "경로: $REMOTE_PATH"
echo "시간: $(date '+%Y-%m-%d %H:%M:%S')"
echo "=========================================="

BIN
next-build_0113.tar.gz Normal file

Binary file not shown.

11
package-lock.json generated
View File

@@ -7629,6 +7629,17 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.18",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -302,7 +302,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
</Button>
</div>

View File

@@ -22,6 +22,13 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
IntegratedListTemplateV2,
type TableColumn,
@@ -456,6 +463,86 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
setCurrentPage(1);
}, []);
// ===== 테이블 헤더 필터 액션 =====
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
{/* 구분 필터 */}
<Select value={categoryFilter} onValueChange={(value) => handleFilterChange('category', value)}>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="구분" />
</SelectTrigger>
<SelectContent>
{VENDOR_CATEGORY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 신용등급 필터 */}
<Select value={creditRatingFilter} onValueChange={(value) => handleFilterChange('creditRating', value)}>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="신용등급" />
</SelectTrigger>
<SelectContent>
{CREDIT_RATING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 거래등급 필터 */}
<Select value={transactionGradeFilter} onValueChange={(value) => handleFilterChange('transactionGrade', value)}>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="거래등급" />
</SelectTrigger>
<SelectContent>
{TRANSACTION_GRADE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 악성채권 필터 */}
<Select value={badDebtFilter} onValueChange={(value) => handleFilterChange('badDebt', value)}>
<SelectTrigger className="w-[100px] h-8">
<SelectValue placeholder="악성채권" />
</SelectTrigger>
<SelectContent>
{BAD_DEBT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 필터 */}
<Select value={sortOption} onValueChange={(value) => handleFilterChange('sort', value)}>
<SelectTrigger className="w-[140px] h-8">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 필터 초기화 버튼 */}
<Button variant="outline" size="sm" onClick={handleFilterReset} className="h-8">
</Button>
</div>
);
return (
<>
<IntegratedListTemplateV2

View File

@@ -1,8 +1,9 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Loader2, List } from 'lucide-react';
import { getExpenseItemOptions, type ExpenseItemOption } from './actions';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
@@ -29,7 +30,7 @@ import type {
import { getEmptyEstimateDetailFormData, estimateDetailToFormData } from './types';
import { ElectronicApprovalModal } from './modals/ElectronicApprovalModal';
import { EstimateDocumentModal } from './modals/EstimateDocumentModal';
import { MOCK_MATERIALS, MOCK_EXPENSES } from './utils';
import { MOCK_MATERIALS } from './utils';
import {
EstimateInfoSection,
EstimateSummarySection,
@@ -75,6 +76,20 @@ export default function EstimateDetailForm({
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 공과 품목 옵션 (Items API에서 조회)
const [expenseOptions, setExpenseOptions] = useState<ExpenseItemOption[]>([]);
// 공과 품목 옵션 조회
useEffect(() => {
async function fetchExpenseOptions() {
const result = await getExpenseItemOptions();
if (result.success && result.data) {
setExpenseOptions(result.data);
}
}
fetchExpenseOptions();
}, []);
// 적용된 조정단가 (전체 적용 버튼 클릭 시 복사됨)
const [appliedPrices, setAppliedPrices] = useState<{
caulking: number;
@@ -202,7 +217,7 @@ export default function EstimateDetailForm({
const handleAddExpenseItems = useCallback((count: number) => {
const newItems = Array.from({ length: count }, () => ({
id: String(Date.now() + Math.random()),
name: MOCK_EXPENSES[0]?.value || '',
name: expenseOptions[0]?.value || '',
amount: 100000,
selected: false,
}));
@@ -210,7 +225,7 @@ export default function EstimateDetailForm({
...prev,
expenseItems: [...prev.expenseItems, ...newItems],
}));
}, []);
}, [expenseOptions]);
const handleRemoveSelectedExpenseItems = useCallback(() => {
const selectedIds = formData.expenseItems
@@ -627,6 +642,7 @@ export default function EstimateDetailForm({
{/* 공과 상세 */}
<ExpenseDetailSection
expenseItems={formData.expenseItems}
expenseOptions={expenseOptions.map((opt) => ({ value: opt.value, label: opt.label }))}
isViewMode={isViewMode}
onAddItems={handleAddExpenseItems}
onRemoveSelected={handleRemoveSelectedExpenseItems}

View File

@@ -639,4 +639,62 @@ export async function deleteEstimates(ids: string[]): Promise<{
console.error('견적 일괄 삭제 오류:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// ========================================
// 공과 품목 조회 (Items API)
// ========================================
/**
* 공과 품목 옵션
*/
export interface ExpenseItemOption {
value: string; // 품목 ID
label: string; // 품목명
code: string; // 품목코드
unit: string; // 단위
}
/**
* 공과 품목 목록 조회
* GET /api/v1/items?type=RM
* 품목관리에서 item_type='RM' (공과) 인 품목만 조회
*/
export async function getExpenseItemOptions(): Promise<{
success: boolean;
data?: ExpenseItemOption[];
error?: string;
}> {
try {
const response = await apiClient.get<{
data: Array<{
id: number;
code: string;
name: string;
unit: string | null;
item_type: string;
is_active: boolean;
}>;
meta?: { total: number };
}>('/items', {
params: {
item_type: 'RM', // 공과 품목만 조회
active: '1', // 활성 품목만
size: '100', // 충분한 수량
},
});
const items = Array.isArray(response.data) ? response.data : [];
const options: ExpenseItemOption[] = items.map((item) => ({
value: String(item.id),
label: item.name,
code: item.code,
unit: item.unit || 'EA',
}));
return { success: true, data: options };
} catch (error) {
console.error('공과 품목 목록 조회 오류:', error);
return { success: false, error: '공과 품목 목록을 불러오는데 실패했습니다.' };
}
}

View File

@@ -20,10 +20,17 @@ import {
TableRow,
} from '@/components/ui/table';
import type { ExpenseItem } from '../types';
import { formatAmount, MOCK_EXPENSES } from '../utils';
import { formatAmount } from '../utils';
// 공과 옵션 타입
interface ExpenseOption {
value: string;
label: string;
}
interface ExpenseDetailSectionProps {
expenseItems: ExpenseItem[];
expenseOptions: ExpenseOption[]; // 공과 품목 옵션 (Items API에서 조회)
isViewMode: boolean;
onAddItems: (count: number) => void;
onRemoveSelected: () => void;
@@ -34,6 +41,7 @@ interface ExpenseDetailSectionProps {
export function ExpenseDetailSection({
expenseItems,
expenseOptions,
isViewMode,
onAddItems,
onRemoveSelected,
@@ -142,11 +150,17 @@ export function ExpenseDetailSection({
<SelectValue placeholder="공과 선택" />
</SelectTrigger>
<SelectContent>
{MOCK_EXPENSES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{expenseOptions.length === 0 ? (
<SelectItem value="_empty" disabled>
</SelectItem>
))}
) : (
expenseOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
</TableCell>

View File

@@ -6,8 +6,5 @@ export const MOCK_MATERIALS = [
{ value: 'jointbar', label: '조인트바' },
];
// 목업 공과 목록
export const MOCK_EXPENSES = [
{ value: 'public_1', label: '공과비 V' },
{ value: 'public_2', label: '공과비 A' },
];
// 공과 품목은 Items API (type=RM)에서 조회
// MOCK_EXPENSES 제거됨 - getExpenseItemOptions() 사용

View File

@@ -183,8 +183,10 @@ export default function ItemDetailClient({
setIsSaving(true);
try {
console.log('📤 [handleSave] formData:', formData);
if (mode === 'new') {
const result = await createItem(formData);
console.log('📥 [handleSave] createItem result:', result);
if (result.success && result.data) {
toast.success('품목이 등록되었습니다.');
router.push(`/ko/construction/order/base-info/items/${result.data.id}`);
@@ -193,6 +195,7 @@ export default function ItemDetailClient({
}
} else if (mode === 'edit' && itemId) {
const result = await updateItem(itemId, formData);
console.log('📥 [handleSave] updateItem result:', result);
if (result.success) {
toast.success('품목이 수정되었습니다.');
setMode('view');
@@ -205,7 +208,8 @@ export default function ItemDetailClient({
toast.error(result.error || '품목 수정에 실패했습니다.');
}
}
} catch {
} catch (error) {
console.error('❌ [handleSave] 오류:', error);
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
@@ -362,7 +366,9 @@ export default function ItemDetailClient({
/>
</div>
<div className="space-y-2">
<Label htmlFor="itemType"></Label>
<Label htmlFor="itemType">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.itemType}
onValueChange={(v) => handleFieldChange('itemType', v as ItemType)}

View File

@@ -383,7 +383,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
<List className="h-4 w-4 mr-2" />
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</div>

View File

@@ -345,17 +345,47 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
</Button>
)}
{order.status === 'in_progress' && (
<>
<Button
variant="outline"
onClick={() => handleStatusChange('waiting')}
disabled={isStatusUpdating}
className="text-muted-foreground hover:text-foreground"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<Undo2 className="w-4 h-4 mr-1.5" />
)}
</Button>
<Button
onClick={() => handleStatusChange('completed')}
disabled={isStatusUpdating}
className="bg-purple-600 hover:bg-purple-700"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-1.5" />
)}
</Button>
</>
)}
{order.status === 'completed' && (
<Button
onClick={() => handleStatusChange('completed')}
variant="outline"
onClick={() => handleStatusChange('in_progress')}
disabled={isStatusUpdating}
className="bg-purple-600 hover:bg-purple-700"
className="text-orange-600 hover:text-orange-700 border-orange-300 hover:bg-orange-50"
>
{isStatusUpdating ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-1.5" />
<Undo2 className="w-4 h-4 mr-1.5" />
)}
</Button>
)}
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>

View File

@@ -29,7 +29,6 @@ import {
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
import { toast } from 'sonner';
import { getWorkResults, getWorkResultStats } from './actions';
import { PROCESS_TYPE_LABELS } from '../WorkOrders/types';
import type { WorkResult, WorkResultStats } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -184,6 +183,12 @@ export function WorkResultList() {
// TODO: 상세 보기 기능 구현
}, []);
// 작업일 포맷팅 (날짜만 표시)
const formatWorkDate = (dateStr: string) => {
if (!dateStr) return '-';
return dateStr.split(' ')[0]; // "2025-01-14 10:30:00" → "2025-01-14"
};
// 테이블 행 렌더링
const renderTableRow = (result: WorkResult, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(result.id);
@@ -202,11 +207,11 @@ export function WorkResultList() {
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell className="font-medium">{result.lotNo}</TableCell>
<TableCell>{result.workDate}</TableCell>
<TableCell>{formatWorkDate(result.workDate)}</TableCell>
<TableCell>{result.workOrderNo}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{PROCESS_TYPE_LABELS[result.processType]}
{result.processName || '-'}
</Badge>
</TableCell>
<TableCell className="max-w-[200px] truncate">{result.productName}</TableCell>
@@ -237,7 +242,7 @@ export function WorkResultList() {
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{result.worker}</TableCell>
<TableCell>{result.workerId ? `#${result.workerId}` : '-'}</TableCell>
</TableRow>
);
};
@@ -265,15 +270,15 @@ export function WorkResultList() {
title={result.productName}
statusBadge={
<Badge variant="outline" className="text-xs">
{PROCESS_TYPE_LABELS[result.processType]}
{result.processName || '-'}
</Badge>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="작업일" value={result.workDate} />
<InfoField label="작업일" value={formatWorkDate(result.workDate)} />
<InfoField label="작업지시번호" value={result.workOrderNo} />
<InfoField label="규격" value={result.specification} />
<InfoField label="작업자" value={result.worker} />
<InfoField label="작업자" value={result.workerId ? `#${result.workerId}` : '-'} />
<InfoField label="생산수량" value={`${result.productionQty}`} />
<InfoField label="양품수량" value={`${result.goodQty}`} />
<InfoField

View File

@@ -1,25 +1,29 @@
/**
* 작업실적 조회 타입 정의
*
* WorkOrderItem.options.result 기반 작업실적 조회
*/
import { ProcessType } from '../WorkOrders/types';
// 작업실적 데이터
// 작업실적 데이터 (Frontend)
export interface WorkResult {
id: string;
lotNo: string; // 로트번호
workDate: string; // 작업일
workDate: string; // 작업 완료
workOrderNo: string; // 작업지시번호
processType: ProcessType; // 공정
projectName: string; // 프로젝트명
processName: string; // 공정명
processCode: string; // 공정코드
productName: string; // 품목명
specification: string; // 규격
productionQty: number; // 생산수량
quantity: number; // 지시수량
productionQty: number; // 생산수량 (양품+불량)
goodQty: number; // 양품수량
defectQty: number; // 불량수량
defectRate: number; // 불량률 (%)
inspection: boolean; // 검사
packaging: boolean; // 포장
worker: string; // 작업자
inspection: boolean; // 검사 완료
packaging: boolean; // 포장 완료
workerId: number | null; // 작업자 ID
memo: string | null; // 메모
}
// 통계 데이터
@@ -32,30 +36,49 @@ export interface WorkResultStats {
// ===== API 타입 정의 =====
// API 응답 - 작업실적
export interface WorkResultApi {
id: number;
tenant_id: number;
work_order_id: number;
work_order_item_id: number | null;
lot_no: string;
work_date: string;
process_type: 'screen' | 'slat' | 'bending';
product_name: string;
specification: string | null;
production_qty: number;
// 작업 결과 데이터 (options.result JSON)
export interface WorkResultData {
completed_at: string;
good_qty: number;
defect_qty: number;
defect_rate: number;
lot_no: string | null;
is_inspected: boolean;
is_packaged: boolean;
worker_id: number | null;
memo: string | null;
}
// API 응답 - 작업실적 (WorkOrderItem 기반)
export interface WorkResultApi {
id: number;
tenant_id: number;
work_order_id: number;
item_id: number | null;
item_name: string;
specification: string | null;
quantity: string; // decimal
unit: string | null;
sort_order: number;
status: 'waiting' | 'in_progress' | 'completed';
options: {
result?: WorkResultData;
} | null;
created_at: string;
updated_at: string;
// Relations
work_order?: { id: number; work_order_no: string };
worker?: { id: number; name: string };
work_order?: {
id: number;
work_order_no: string;
project_name: string | null;
process_id: number | null;
completed_at: string | null;
process?: {
id: number;
process_name: string;
process_code: string;
} | null;
};
}
// API 페이징 응답
@@ -79,40 +102,42 @@ export interface WorkResultStatsApi {
// API → Frontend 변환
export function transformApiToFrontend(api: WorkResultApi): WorkResult {
const result = api.options?.result;
const goodQty = result?.good_qty ?? 0;
const defectQty = result?.defect_qty ?? 0;
return {
id: String(api.id),
lotNo: api.lot_no,
workDate: api.work_date,
lotNo: result?.lot_no || '-',
workDate: result?.completed_at || api.updated_at,
workOrderNo: api.work_order?.work_order_no || '-',
processType: api.process_type,
productName: api.product_name,
projectName: api.work_order?.project_name || '-',
processName: api.work_order?.process?.process_name || '-',
processCode: api.work_order?.process?.process_code || '-',
productName: api.item_name,
specification: api.specification || '-',
productionQty: api.production_qty,
goodQty: api.good_qty,
defectQty: api.defect_qty,
defectRate: api.defect_rate,
inspection: api.is_inspected,
packaging: api.is_packaged,
worker: api.worker?.name || '-',
quantity: parseFloat(api.quantity) || 0,
productionQty: goodQty + defectQty,
goodQty: goodQty,
defectQty: defectQty,
defectRate: result?.defect_rate ?? 0,
inspection: result?.is_inspected ?? false,
packaging: result?.is_packaged ?? false,
workerId: result?.worker_id ?? null,
memo: result?.memo ?? null,
};
}
// Frontend → API 변환 (등록/수정용)
export function transformFrontendToApi(data: Partial<WorkResult> & { workOrderId?: number; workerId?: number }): Record<string, unknown> {
// Frontend → API 변환 (수정용)
export function transformFrontendToApi(data: Partial<WorkResult>): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (data.workOrderId !== undefined) result.work_order_id = data.workOrderId;
if (data.lotNo !== undefined) result.lot_no = data.lotNo;
if (data.workDate !== undefined) result.work_date = data.workDate;
if (data.processType !== undefined) result.process_type = data.processType;
if (data.productName !== undefined) result.product_name = data.productName;
if (data.specification !== undefined) result.specification = data.specification;
if (data.productionQty !== undefined) result.production_qty = data.productionQty;
if (data.goodQty !== undefined) result.good_qty = data.goodQty;
if (data.defectQty !== undefined) result.defect_qty = data.defectQty;
if (data.inspection !== undefined) result.is_inspected = data.inspection;
if (data.packaging !== undefined) result.is_packaged = data.packaging;
if (data.workerId !== undefined) result.worker_id = data.workerId;
if (data.memo !== undefined) result.memo = data.memo;
return result;
}

View File

@@ -66,7 +66,7 @@ interface InspectionApiItem {
}
interface InspectionApiPaginatedResponse {
data: InspectionApiItem[];
items: InspectionApiItem[];
current_page: number;
last_page: number;
per_page: number;
@@ -247,7 +247,7 @@ export async function getInspections(params?: {
}
const paginatedData: InspectionApiPaginatedResponse = result.data || {
data: [],
items: [],
current_page: 1,
last_page: 1,
per_page: 20,
@@ -256,7 +256,7 @@ export async function getInspections(params?: {
return {
success: true,
data: paginatedData.data.map(transformApiToFrontend),
data: paginatedData.items.map(transformApiToFrontend),
pagination: {
currentPage: paginatedData.current_page,
lastPage: paginatedData.last_page,

File diff suppressed because one or more lines are too long