feat: [pm] 이슈에 팀/담당자/고객사 필드 추가

- DB 마이그레이션: 하이브리드 FK + 문자열 필드 방식
- Model: fillable, casts, relationships, accessor 추가
- FormRequest: validation rules 추가 (Store/Update)
- ImportService: JSON import 시 새 필드 처리
- UI: 이슈 모달에 입력 필드 추가
- UI: 작업 탭 아코디언에 고객사·부서·담당자 표시
- 이슈 저장 후 작업 탭 즉시 갱신
This commit is contained in:
2025-12-02 19:10:15 +09:00
parent 8b88224be9
commit d4051e20fa
6 changed files with 143 additions and 4 deletions

View File

@@ -36,6 +36,17 @@ public function rules(): array
'tasks.*.issues.*.description' => 'nullable|string',
'tasks.*.issues.*.type' => 'nullable|in:bug,feature,improvement',
'tasks.*.issues.*.status' => 'nullable|in:open,in_progress,resolved,closed',
// 일정 관련
'tasks.*.issues.*.start_date' => 'nullable|date',
'tasks.*.issues.*.due_date' => 'nullable|date|after_or_equal:tasks.*.issues.*.start_date',
'tasks.*.issues.*.estimated_hours' => 'nullable|integer|min:0',
'tasks.*.issues.*.is_urgent' => 'nullable|boolean',
// 팀/담당자/고객사 (하이브리드)
'tasks.*.issues.*.department_id' => 'nullable|integer|exists:departments,id',
'tasks.*.issues.*.team' => 'nullable|string|max:100',
'tasks.*.issues.*.assignee_id' => 'nullable|integer|exists:users,id',
'tasks.*.issues.*.assignee_name' => 'nullable|string|max:100',
'tasks.*.issues.*.client' => 'nullable|string|max:100',
];
}
@@ -48,4 +59,4 @@ public function messages(): array
'tasks.*.issues.*.title.required' => '각 이슈의 title은 필수입니다.',
];
}
}
}

View File

@@ -30,6 +30,12 @@ public function rules(): array
'start_date' => 'nullable|date',
'due_date' => 'nullable|date|after_or_equal:start_date',
'estimated_hours' => 'nullable|integer|min:0|max:9999',
// 팀/담당자/고객사 (하이브리드)
'department_id' => 'nullable|integer|exists:departments,id',
'team' => 'nullable|string|max:100',
'assignee_id' => 'nullable|integer|exists:users,id',
'assignee_name' => 'nullable|string|max:100',
'client' => 'nullable|string|max:100',
];
}
@@ -48,6 +54,11 @@ public function attributes(): array
'start_date' => '시작일',
'due_date' => '마감일',
'estimated_hours' => '예상 시간',
'department_id' => '부서',
'team' => '팀/부서명',
'assignee_id' => '담당자',
'assignee_name' => '담당자명',
'client' => '고객사',
];
}

View File

@@ -30,6 +30,12 @@ public function rules(): array
'start_date' => 'nullable|date',
'due_date' => 'nullable|date|after_or_equal:start_date',
'estimated_hours' => 'nullable|integer|min:0|max:9999',
// 팀/담당자/고객사 (하이브리드)
'department_id' => 'nullable|integer|exists:departments,id',
'team' => 'nullable|string|max:100',
'assignee_id' => 'nullable|integer|exists:users,id',
'assignee_name' => 'nullable|string|max:100',
'client' => 'nullable|string|max:100',
];
}
@@ -48,6 +54,11 @@ public function attributes(): array
'start_date' => '시작일',
'due_date' => '마감일',
'estimated_hours' => '예상 시간',
'department_id' => '부서',
'team' => '팀/부서명',
'assignee_id' => '담당자',
'assignee_name' => '담당자명',
'client' => '고객사',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Models\Admin;
use App\Models\Department;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -41,6 +42,12 @@ class AdminPmIssue extends Model
'due_date',
'estimated_hours',
'is_urgent',
// 팀/담당자/고객사 (하이브리드)
'department_id',
'team',
'assignee_id',
'assignee_name',
'client',
'created_by',
'updated_by',
'deleted_by',
@@ -53,6 +60,8 @@ class AdminPmIssue extends Model
'due_date' => 'date',
'estimated_hours' => 'integer',
'is_urgent' => 'boolean',
'department_id' => 'integer',
'assignee_id' => 'integer',
'created_by' => 'integer',
'updated_by' => 'integer',
'deleted_by' => 'integer',
@@ -159,6 +168,46 @@ public function updater(): BelongsTo
return $this->belongsTo(User::class, 'updated_by');
}
/**
* 관계: 부서 (연동 시)
*/
public function department(): BelongsTo
{
return $this->belongsTo(Department::class, 'department_id');
}
/**
* 관계: 담당자 (연동 시)
*/
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
/**
* 팀/부서 표시명 (FK 우선, 없으면 문자열)
*/
public function getTeamDisplayAttribute(): ?string
{
if ($this->department_id && $this->department) {
return $this->department->name;
}
return $this->team;
}
/**
* 담당자 표시명 (FK 우선, 없으면 문자열)
*/
public function getAssigneeDisplayAttribute(): ?string
{
if ($this->assignee_id && $this->assignee) {
return $this->assignee->name;
}
return $this->assignee_name;
}
/**
* 타입 아이콘
*/

View File

@@ -60,6 +60,16 @@ public function importFromJson(array $data): array
'description' => $issueData['description'] ?? null,
'type' => $issueData['type'] ?? AdminPmIssue::TYPE_BUG,
'status' => $issueData['status'] ?? AdminPmIssue::STATUS_OPEN,
'start_date' => $issueData['start_date'] ?? null,
'due_date' => $issueData['due_date'] ?? null,
'estimated_hours' => $issueData['estimated_hours'] ?? null,
'is_urgent' => $issueData['is_urgent'] ?? false,
// 팀/담당자/고객사 (하이브리드)
'department_id' => $issueData['department_id'] ?? null,
'team' => $issueData['team'] ?? null,
'assignee_id' => $issueData['assignee_id'] ?? null,
'assignee_name' => $issueData['assignee_name'] ?? null,
'client' => $issueData['client'] ?? null,
'created_by' => auth()->id(),
]);
$result['issues_count']++;
@@ -109,6 +119,16 @@ public function importTasksToProject(int $projectId, array $tasks): array
'description' => $issueData['description'] ?? null,
'type' => $issueData['type'] ?? AdminPmIssue::TYPE_BUG,
'status' => $issueData['status'] ?? AdminPmIssue::STATUS_OPEN,
'start_date' => $issueData['start_date'] ?? null,
'due_date' => $issueData['due_date'] ?? null,
'estimated_hours' => $issueData['estimated_hours'] ?? null,
'is_urgent' => $issueData['is_urgent'] ?? false,
// 팀/담당자/고객사 (하이브리드)
'department_id' => $issueData['department_id'] ?? null,
'team' => $issueData['team'] ?? null,
'assignee_id' => $issueData['assignee_id'] ?? null,
'assignee_name' => $issueData['assignee_name'] ?? null,
'client' => $issueData['client'] ?? null,
'created_by' => auth()->id(),
]);
$result['issues_count']++;
@@ -143,8 +163,19 @@ public function getSampleTemplate(): array
[
'title' => '이슈 1',
'description' => '이슈 설명',
'type' => 'bug',
'type' => 'feature',
'status' => 'open',
'start_date' => date('Y-m-d'),
'due_date' => date('Y-m-d', strtotime('+1 week')),
'estimated_hours' => 8,
'is_urgent' => false,
// 방법 1: FK 연동 (DB에 존재하는 ID)
'department_id' => null,
'assignee_id' => null,
// 방법 2: 문자열 직접 입력 (연동 없이)
'team' => '개발팀',
'assignee_name' => '홍길동',
'client' => '경동기업',
],
],
],
@@ -188,4 +219,4 @@ public function validateJsonStructure(array $data): array
return $errors;
}
}
}

View File

@@ -302,6 +302,25 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
</div>
</div>
<!-- /담당자/고객사 -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">/부서</label>
<input type="text" name="team" id="issueTeam" placeholder="예: 개발팀"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">담당자</label>
<input type="text" name="assignee_name" id="issueAssigneeName" placeholder="예: 홍길동"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">고객사</label>
<input type="text" name="client" id="issueClient" placeholder="예: 경동기업"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" onclick="closeIssueModal()"
class="px-4 py-2 text-gray-600 hover:text-gray-800">취소</button>
@@ -575,7 +594,9 @@ function renderTasks(container, tasks) {
<td class="py-1 text-center text-xs ${issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== 'resolved' && issue.status !== 'closed' ? 'text-red-500 font-medium' : 'text-gray-500'}">
${issue.due_date ? formatDate(issue.due_date) : '-'}
</td>
<td class="py-1 text-center text-xs text-gray-400">-</td>
<td class="py-1 text-center text-xs text-gray-500">
${[issue.client, issue.team, issue.assignee_name].filter(Boolean).join(' · ') || '-'}
</td>
<td class="py-1 text-center">
<span class="px-1.5 py-0.5 text-xs rounded ${issueStatusColors[issue.status]}">${issueStatusLabels[issue.status]}</span>
</td>
@@ -896,6 +917,10 @@ function closeIssueModal() {
document.getElementById('issueStartDate').value = issue.start_date ? formatDate(issue.start_date) : '';
document.getElementById('issueDueDate').value = issue.due_date ? formatDate(issue.due_date) : '';
document.getElementById('issueEstimatedHours').value = issue.estimated_hours || '';
// 팀/담당자/고객사
document.getElementById('issueTeam').value = issue.team || '';
document.getElementById('issueAssigneeName').value = issue.assignee_name || '';
document.getElementById('issueClient').value = issue.client || '';
document.getElementById('issueModal').classList.remove('hidden');
}
}
@@ -920,6 +945,7 @@ function closeIssueModal() {
if (result.success) {
closeIssueModal();
loadIssues();
loadTasks(); // 작업 탭 아코디언도 업데이트
} else {
alert(result.message || '저장에 실패했습니다.');
}