EENE Dashboard upload to Gitea
This commit is contained in:
@@ -261,10 +261,9 @@ function buildMilestones(p: HrProject): MappedTask['milestones'] {
|
|||||||
return milestones;
|
return milestones;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDetailContent(p: HrProject): string | null {
|
/** @deprecated progressStatus는 milestone·periodEntries로 이관 — TaskDetail(피드백)에 넣지 않음 */
|
||||||
const content = p.progressStatus?.trim() || p.progressLog?.trim();
|
function buildDetailContent(_p: HrProject): string | null {
|
||||||
if (!content || content === '이슈사항' || content === '12') return null;
|
return null;
|
||||||
return content;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
|
export function mapHrProjectToTask(p: HrProject, quarter = '2026-Q2'): MappedTask {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tasks" ADD COLUMN "detailDescription" TEXT;
|
||||||
|
|
||||||
|
-- 기존 description(첫 줄=개요, 이후=상세) → 분리 (실행과제·프로젝트만)
|
||||||
|
UPDATE "tasks"
|
||||||
|
SET
|
||||||
|
"detailDescription" = CASE
|
||||||
|
WHEN position(E'\n' in "description") > 0
|
||||||
|
THEN NULLIF(trim(substring("description" from position(E'\n' in "description") + 1)), '')
|
||||||
|
ELSE NULL
|
||||||
|
END,
|
||||||
|
"description" = NULLIF(trim(split_part("description", E'\n', 1)), '')
|
||||||
|
WHERE "description" IS NOT NULL
|
||||||
|
AND trim("description") != ''
|
||||||
|
AND ("taskType" = '실행과제' OR "taskType" = '프로젝트');
|
||||||
@@ -66,6 +66,7 @@ model Task {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
|
detailDescription String?
|
||||||
status TaskStatus @default(TODO)
|
status TaskStatus @default(TODO)
|
||||||
priority Priority @default(MEDIUM)
|
priority Priority @default(MEDIUM)
|
||||||
quarter String // 예: "2026-Q2"
|
quarter String // 예: "2026-Q2"
|
||||||
|
|||||||
54
backend/scripts/cleanup-legacy-task-details.ts
Normal file
54
backend/scripts/cleanup-legacy-task-details.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* HR import 시 TaskDetail(피드백)에 잘못 들어간 progressStatus 레거시 삭제
|
||||||
|
* — milestoneId 없고 authorName 없는 행 (seed 자동 생성분)
|
||||||
|
*
|
||||||
|
* npx tsx scripts/cleanup-legacy-task-details.ts
|
||||||
|
* npx tsx scripts/cleanup-legacy-task-details.ts --dry-run
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const dryRun = process.argv.includes('--dry-run');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const legacy = await prisma.taskDetail.findMany({
|
||||||
|
where: {
|
||||||
|
milestoneId: null,
|
||||||
|
OR: [{ authorName: null }, { authorName: '' }],
|
||||||
|
},
|
||||||
|
include: { task: { select: { title: true } } },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacy.length === 0) {
|
||||||
|
console.log('✅ 삭제할 레거시 피드백 없음');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`레거시 TaskDetail ${legacy.length}건 (일정 미연결 · 작성자 없음):`);
|
||||||
|
for (const row of legacy) {
|
||||||
|
const preview = row.content.replace(/\s+/g, ' ').slice(0, 72);
|
||||||
|
console.log(` · [${row.task.title}] ${preview}${row.content.length > 72 ? '…' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
console.log('\n(dry-run — 삭제하지 않음. 적용: npx tsx scripts/cleanup-legacy-task-details.ts)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await prisma.taskDetail.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: legacy.map((r) => r.id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ ${result.count}건 삭제 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
77
backend/scripts/normalize-task-sections.ts
Normal file
77
backend/scripts/normalize-task-sections.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* task.section 레거시 값 정규화 + 조직문화(EX) 재배치
|
||||||
|
* npx tsx scripts/normalize-task-sections.ts
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
const EX_TITLE = /회사생활|C\.E\.L|조직문화|복리후생|문화\s*진단|직원\s*소통/i;
|
||||||
|
|
||||||
|
const SECTION_MAP: Record<string, string> = {
|
||||||
|
성장지원: '학습성장',
|
||||||
|
HR: '인사관리',
|
||||||
|
운영지원: '운영관리',
|
||||||
|
전산관리: '운영관리',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
select: { id: true, title: true, section: true, category: true, taskType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let renamed = 0;
|
||||||
|
let exMoved = 0;
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const section = task.section?.trim() ?? '';
|
||||||
|
let nextSection = SECTION_MAP[section] ?? section;
|
||||||
|
|
||||||
|
if (nextSection !== '조직문화' && EX_TITLE.test(task.title.trim())) {
|
||||||
|
nextSection = '조직문화';
|
||||||
|
exMoved += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: { section?: string; category?: string | null } = {};
|
||||||
|
if (nextSection && nextSection !== section) {
|
||||||
|
patch.section = nextSection;
|
||||||
|
renamed += 1;
|
||||||
|
}
|
||||||
|
if (nextSection === '조직문화' && task.category !== '조직문화') {
|
||||||
|
patch.category = '조직문화';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
await prisma.task.update({ where: { id: task.id }, data: patch });
|
||||||
|
console.log(` section: ${section || '(empty)'} → ${nextSection} | ${task.title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const col = await prisma.columnConfig.findUnique({ where: { key: '운영관리' } });
|
||||||
|
if (col && (col.title === '운영관리' || col.title === '운영관리 부문' || col.titleEn === 'Operations')) {
|
||||||
|
await prisma.columnConfig.update({
|
||||||
|
where: { key: '운영관리' },
|
||||||
|
data: { title: '총무관리', titleEn: 'GA' },
|
||||||
|
});
|
||||||
|
console.log(' columnConfig 운영관리 → 총무관리');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrd = await prisma.columnConfig.findUnique({ where: { key: '학습성장' } });
|
||||||
|
if (hrd && (hrd.title === '학습성장' || hrd.title === '성장지원' || hrd.titleEn === 'Learning & Growth')) {
|
||||||
|
await prisma.columnConfig.update({
|
||||||
|
where: { key: '학습성장' },
|
||||||
|
data: { title: '인재육성', titleEn: 'HRD' },
|
||||||
|
});
|
||||||
|
console.log(' columnConfig 학습성장 → 인재육성');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ normalize-task-sections complete (${renamed} renamed, ${exMoved} → 조직문화)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -2,12 +2,20 @@ export interface TaskIssueEntry {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
showOnCard: boolean;
|
showOnCard: boolean;
|
||||||
|
occurredOn?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function newIssueId() {
|
function newIssueId() {
|
||||||
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
return `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOccurredOn(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
const iso = raw.trim();
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return null;
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const entries: TaskIssueEntry[] = [];
|
const entries: TaskIssueEntry[] = [];
|
||||||
@@ -20,6 +28,7 @@ export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
|||||||
id: typeof row.id === 'string' && row.id ? row.id : newIssueId(),
|
id: typeof row.id === 'string' && row.id ? row.id : newIssueId(),
|
||||||
text,
|
text,
|
||||||
showOnCard: row.showOnCard !== false,
|
showOnCard: row.showOnCard !== false,
|
||||||
|
occurredOn: normalizeOccurredOn(row.occurredOn),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -34,7 +43,7 @@ export function parseIssueEntriesFromTask(task: {
|
|||||||
if (fromJson.length > 0) return fromJson;
|
if (fromJson.length > 0) return fromJson;
|
||||||
const legacy = task.issueNote?.trim();
|
const legacy = task.issueNote?.trim();
|
||||||
if (!legacy) return [];
|
if (!legacy) return [];
|
||||||
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
|
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false, occurredOn: null }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveIssueFields(entries: TaskIssueEntry[]) {
|
export function deriveIssueFields(entries: TaskIssueEntry[]) {
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ export const taskInclude = {
|
|||||||
taskAssignees: {
|
taskAssignees: {
|
||||||
include: { member: { select: teamMemberSelect } },
|
include: { member: { select: teamMemberSelect } },
|
||||||
},
|
},
|
||||||
|
milestones: {
|
||||||
|
orderBy: { order: 'asc' as const },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
progress: true,
|
||||||
|
startDate: true,
|
||||||
|
dueDate: true,
|
||||||
|
periodEntries: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
_count: { select: { files: true, details: true } },
|
_count: { select: { files: true, details: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ router.get('/:id', async (req, res, next) => {
|
|||||||
router.post('/', async (req, res, next) => {
|
router.post('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body as Record<string, any>;
|
const body = req.body as Record<string, any>;
|
||||||
const { title, description, status, priority, quarter, category,
|
const { title, description, detailDescription, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
detailDescription: detailDescription ?? null,
|
||||||
status: (status as any) || 'TODO',
|
status: (status as any) || 'TODO',
|
||||||
priority: (priority as any) || 'MEDIUM',
|
priority: (priority as any) || 'MEDIUM',
|
||||||
quarter,
|
quarter,
|
||||||
@@ -135,7 +136,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
if (!existing) throw new AppError(404, '업무를 찾을 수 없습니다.');
|
||||||
|
|
||||||
const body = req.body as Record<string, any>;
|
const body = req.body as Record<string, any>;
|
||||||
const { title, description, status, priority, quarter, category,
|
const { title, description, detailDescription, status, priority, quarter, category,
|
||||||
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
section, tag, taskType, progress, issueNote, startDate, dueDate, assigneeId, showDate,
|
||||||
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
showDescription, showStatus, showIssue, showProgress, pmMemberId } = body;
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
data: {
|
data: {
|
||||||
...(title && { title }),
|
...(title && { title }),
|
||||||
...(description !== undefined && { description }),
|
...(description !== undefined && { description }),
|
||||||
|
...(detailDescription !== undefined && { detailDescription: detailDescription || null }),
|
||||||
...(status && { status: status as any }),
|
...(status && { status: status as any }),
|
||||||
...(priority && { priority: priority as any }),
|
...(priority && { priority: priority as any }),
|
||||||
...(quarter && { quarter }),
|
...(quarter && { quarter }),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
data/postgres/base/16384/24625_fsm
Normal file
BIN
data/postgres/base/16384/24625_fsm
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
262
frontend/src/components/common/StageFormFields.tsx
Normal file
262
frontend/src/components/common/StageFormFields.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import type { StageFormData } from '../detail/stageFormTypes';
|
||||||
|
import { newPeriodEntry } from '../../lib/milestonePeriods';
|
||||||
|
import type { TeamMember } from '../../types';
|
||||||
|
|
||||||
|
interface StageFormFieldsProps {
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
form: StageFormData;
|
||||||
|
onChange: (next: StageFormData) => void;
|
||||||
|
teamMembers?: TeamMember[];
|
||||||
|
idPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StageFormFields({
|
||||||
|
variant,
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
teamMembers = [],
|
||||||
|
idPrefix = 'stage-form',
|
||||||
|
}: StageFormFieldsProps) {
|
||||||
|
const isRoutine = variant === 'routine';
|
||||||
|
|
||||||
|
const set = <K extends keyof StageFormData>(field: K, value: StageFormData[K]) =>
|
||||||
|
onChange({ ...form, [field]: value });
|
||||||
|
|
||||||
|
const updatePeriodEntry = (id: string, patch: Partial<(typeof form.periodEntries)[0]>) => {
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
periodEntries: form.periodEntries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAssignee = (memberId: string) => {
|
||||||
|
const has = form.assigneeMemberIds.includes(memberId);
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
assigneeMemberIds: has
|
||||||
|
? form.assigneeMemberIds.filter((id) => id !== memberId)
|
||||||
|
: [...form.assigneeMemberIds, memberId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-form-fields">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-title`}>
|
||||||
|
단계 제목 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-title`}
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => set('title', e.target.value)}
|
||||||
|
className="task-form-input task-form-input--title"
|
||||||
|
placeholder="업무 일정 제목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRoutine && (
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-subtitle`}>
|
||||||
|
부제목
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-subtitle`}
|
||||||
|
value={form.subtitle}
|
||||||
|
onChange={(e) => set('subtitle', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
placeholder="업무명 아래 표시 (선택)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-progress`}>
|
||||||
|
진행률 <span className="task-form-progress-val">{form.progress}%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-progress`}
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={form.progress}
|
||||||
|
onChange={(e) => set('progress', Number(e.target.value))}
|
||||||
|
className="task-form-range"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-form-field">
|
||||||
|
<div className="task-form-label-row">
|
||||||
|
<span className="task-form-label">수행 기간</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn"
|
||||||
|
onClick={() => onChange({ ...form, periodEntries: [...form.periodEntries, newPeriodEntry()] })}
|
||||||
|
>
|
||||||
|
+ 기간 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.periodEntries.length === 0 ? (
|
||||||
|
<p className="task-form-empty">등록된 기간이 없습니다. 보류 후 재개·분기별 수행 등 기간을 추가하세요.</p>
|
||||||
|
) : (
|
||||||
|
<div className="task-form-issues">
|
||||||
|
{form.periodEntries.map((entry, index) => (
|
||||||
|
<div key={entry.id} className="task-form-issue">
|
||||||
|
<div className="task-form-label-row">
|
||||||
|
<span className="task-form-label">기간 {index + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn task-form-link-btn--danger"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
periodEntries: form.periodEntries.filter((e) => e.id !== entry.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-row-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={entry.startDate}
|
||||||
|
onChange={(e) => updatePeriodEntry(entry.id, { startDate: e.target.value })}
|
||||||
|
className="task-form-input"
|
||||||
|
aria-label={`기간 ${index + 1} 시작일`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={entry.dueDate}
|
||||||
|
onChange={(e) => updatePeriodEntry(entry.id, { dueDate: e.target.value })}
|
||||||
|
className="task-form-input"
|
||||||
|
aria-label={`기간 ${index + 1} 종료일`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={entry.note}
|
||||||
|
onChange={(e) => updatePeriodEntry(entry.id, { note: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
placeholder="이 기간에 수행한 내용"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRoutine && teamMembers.length > 0 && (
|
||||||
|
<div className="task-form-people">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-pm`}>
|
||||||
|
PM
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-pm`}
|
||||||
|
value={form.pmMemberId}
|
||||||
|
onChange={(e) => set('pmMemberId', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
<option value="">선택 안 함</option>
|
||||||
|
{teamMembers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
{m.rank ? ` · ${m.rank}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<span className="task-form-label">담당자 (복수 선택)</span>
|
||||||
|
<div className="task-form-assignees">
|
||||||
|
{teamMembers.map((m) => {
|
||||||
|
const checked = form.assigneeMemberIds.includes(m.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={`task-form-assignee-chip${checked ? ' is-checked' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleAssignee(m.id)}
|
||||||
|
/>
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="task-form-field">
|
||||||
|
<div className="task-form-label-row">
|
||||||
|
<span className="task-form-label">링크</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
links: [...form.links, { label: '', url: '' }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ 링크 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.links.length === 0 ? (
|
||||||
|
<p className="task-form-empty">등록된 링크가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="task-form-issues">
|
||||||
|
{form.links.map((link, index) => (
|
||||||
|
<div key={index} className="task-form-issue">
|
||||||
|
<div className="task-form-row-2">
|
||||||
|
<input
|
||||||
|
value={link.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const links = [...form.links];
|
||||||
|
links[index] = { ...links[index], label: e.target.value };
|
||||||
|
onChange({ ...form, links });
|
||||||
|
}}
|
||||||
|
className="task-form-input"
|
||||||
|
placeholder="표시명"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={link.url}
|
||||||
|
onChange={(e) => {
|
||||||
|
const links = [...form.links];
|
||||||
|
links[index] = { ...links[index], url: e.target.value };
|
||||||
|
onChange({ ...form, links });
|
||||||
|
}}
|
||||||
|
className="task-form-input"
|
||||||
|
placeholder="URL"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-issue-actions">
|
||||||
|
<span />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn task-form-link-btn--danger"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
links: form.links.filter((_, i) => i !== index),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
333
frontend/src/components/common/TaskFormFields.tsx
Normal file
333
frontend/src/components/common/TaskFormFields.tsx
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import type { TaskFormData } from '../../lib/taskFormState';
|
||||||
|
import { STATUS_OPTIONS } from '../../lib/taskFormState';
|
||||||
|
import { routineCategoryOptions } from '../../lib/routineCategories';
|
||||||
|
import { newIssueEntry } from '../../lib/taskIssues';
|
||||||
|
import type { TeamMember } from '../../types';
|
||||||
|
|
||||||
|
type SectionOption = { value: string; label: string };
|
||||||
|
|
||||||
|
interface TaskFormFieldsProps {
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
form: TaskFormData;
|
||||||
|
onChange: (next: TaskFormData) => void;
|
||||||
|
sectionOptions?: SectionOption[];
|
||||||
|
teamMembers?: TeamMember[];
|
||||||
|
idPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskFormFields({
|
||||||
|
variant,
|
||||||
|
form,
|
||||||
|
onChange,
|
||||||
|
sectionOptions,
|
||||||
|
teamMembers = [],
|
||||||
|
idPrefix = 'task-form',
|
||||||
|
}: TaskFormFieldsProps) {
|
||||||
|
const isRoutine = variant === 'routine';
|
||||||
|
|
||||||
|
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
|
||||||
|
onChange({ ...form, [field]: value });
|
||||||
|
|
||||||
|
const toggleAssignee = (memberId: string) => {
|
||||||
|
const has = form.assigneeMemberIds.includes(memberId);
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
assigneeMemberIds: has
|
||||||
|
? form.assigneeMemberIds.filter((id) => id !== memberId)
|
||||||
|
: [...form.assigneeMemberIds, memberId],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateIssueEntry = (id: string, patch: Partial<(typeof form.issueEntries)[0]>) => {
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
issueEntries: form.issueEntries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="task-form-fields">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-title`}>
|
||||||
|
제목 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-title`}
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => set('title', e.target.value)}
|
||||||
|
className="task-form-input task-form-input--title"
|
||||||
|
placeholder="업무 제목을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-section`}>
|
||||||
|
{isRoutine ? '대분류' : '소속 부문'}
|
||||||
|
</label>
|
||||||
|
{isRoutine ? (
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-section`}
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => set('category', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
{routineCategoryOptions().map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-section`}
|
||||||
|
value={form.section}
|
||||||
|
onChange={(e) => set('section', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
{(sectionOptions ?? [
|
||||||
|
{ value: '인사관리', label: '인사관리' },
|
||||||
|
{ value: '운영관리', label: '총무관리' },
|
||||||
|
]).map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isRoutine && (
|
||||||
|
<div className="task-form-row-2">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-status`}>
|
||||||
|
상태
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-status`}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => set('status', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-progress`}>
|
||||||
|
진행률 <span className="task-form-progress-val">{form.progress}%</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${idPrefix}-progress`}
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={form.progress}
|
||||||
|
onChange={(e) => set('progress', Number(e.target.value))}
|
||||||
|
className="task-form-range"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRoutine && (
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-status`}>
|
||||||
|
상태
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-status`}
|
||||||
|
value={form.status}
|
||||||
|
onChange={(e) => set('status', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRoutine ? (
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-desc`}>
|
||||||
|
내용
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`${idPrefix}-desc`}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
placeholder="내용을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-overview`}>
|
||||||
|
개요
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`${idPrefix}-overview`}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => set('description', e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
placeholder="프로젝트 개요를 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-detail`}>
|
||||||
|
상세내용
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`${idPrefix}-detail`}
|
||||||
|
value={form.detailDescription}
|
||||||
|
onChange={(e) => set('detailDescription', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="task-form-input task-form-textarea task-form-textarea--tall"
|
||||||
|
placeholder="상세 내용을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamMembers.length > 0 && (
|
||||||
|
<div className="task-form-people">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor={`${idPrefix}-pm`}>
|
||||||
|
PM
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`${idPrefix}-pm`}
|
||||||
|
value={form.pmMemberId}
|
||||||
|
onChange={(e) => set('pmMemberId', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
<option value="">선택 안 함</option>
|
||||||
|
{teamMembers.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
{m.rank ? ` · ${m.rank}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<span className="task-form-label">담당자 (복수 선택)</span>
|
||||||
|
<div className="task-form-assignees">
|
||||||
|
{teamMembers.map((m) => {
|
||||||
|
const checked = form.assigneeMemberIds.includes(m.id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.id}
|
||||||
|
className={`task-form-assignee-chip${checked ? ' is-checked' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleAssignee(m.id)}
|
||||||
|
/>
|
||||||
|
{m.name}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRoutine && (
|
||||||
|
<div className="task-form-field">
|
||||||
|
<span className="task-form-label">기간</span>
|
||||||
|
<div className="task-form-row-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.startDate}
|
||||||
|
onChange={(e) => set('startDate', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
aria-label="시작일"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.dueDate}
|
||||||
|
onChange={(e) => set('dueDate', e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
aria-label="종료일"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="task-form-field">
|
||||||
|
<div className="task-form-label-row">
|
||||||
|
<span className="task-form-label">이슈 메모</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ ...form, issueEntries: [...form.issueEntries, newIssueEntry()] })}
|
||||||
|
className="task-form-link-btn"
|
||||||
|
>
|
||||||
|
+ 이슈 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.issueEntries.length === 0 ? (
|
||||||
|
<p className="task-form-empty">등록된 이슈가 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="task-form-issues">
|
||||||
|
{form.issueEntries.map((entry, index) => (
|
||||||
|
<div key={entry.id} className="task-form-issue">
|
||||||
|
<textarea
|
||||||
|
value={entry.text}
|
||||||
|
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
placeholder={`이슈 내용 (${index + 1})`}
|
||||||
|
/>
|
||||||
|
<div className="task-form-issue-actions">
|
||||||
|
<label className="task-form-issue-date">
|
||||||
|
<span className="task-form-issue-date-label">발생일</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={entry.occurredOn ?? ''}
|
||||||
|
onChange={(e) => updateIssueEntry(entry.id, { occurredOn: e.target.value || null })}
|
||||||
|
className="task-form-input task-form-issue-date-input"
|
||||||
|
aria-label={`이슈 ${index + 1} 발생일`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="task-form-issue-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={entry.showOnCard}
|
||||||
|
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
|
||||||
|
/>
|
||||||
|
카드 표시
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onChange({
|
||||||
|
...form,
|
||||||
|
issueEntries: form.issueEntries.filter((e) => e.id !== entry.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="task-form-link-btn task-form-link-btn--danger"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { Task, TeamMember, TaskIssueEntry } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
import { normalizeTaskType } from '../../lib/taskType';
|
import { TaskFormFields } from './TaskFormFields';
|
||||||
import { newIssueEntry, parseIssueEntries } from '../../lib/taskIssues';
|
import { buildTaskFormState, type TaskFormData } from '../../lib/taskFormState';
|
||||||
import { getRoutineCategory, routineCategoryOptions } from '../../lib/routineCategories';
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
export type { TaskFormData };
|
||||||
{ value: 'TODO', label: '대기' },
|
|
||||||
{ value: 'IN_PROGRESS', label: '진행' },
|
|
||||||
{ value: 'REVIEW', label: '보류' },
|
|
||||||
{ value: 'DONE', label: '완료' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface TaskFormData {
|
|
||||||
title: string;
|
|
||||||
section: string;
|
|
||||||
category: string;
|
|
||||||
tag: string;
|
|
||||||
taskType: string;
|
|
||||||
status: string;
|
|
||||||
progress: number;
|
|
||||||
description: string;
|
|
||||||
issueEntries: TaskIssueEntry[];
|
|
||||||
quarter: string;
|
|
||||||
startDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
pmMemberId: string;
|
|
||||||
assigneeMemberIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskModalProps {
|
interface TaskModalProps {
|
||||||
mode: 'add' | 'edit';
|
mode: 'add' | 'edit';
|
||||||
/** project: 실행과제 전용 / routine: 기반업무(상시) 전용 */
|
|
||||||
variant?: 'project' | 'routine';
|
variant?: 'project' | 'routine';
|
||||||
task?: Task;
|
task?: Task;
|
||||||
defaultSection?: string;
|
defaultSection?: string;
|
||||||
@@ -47,7 +23,7 @@ export function TaskModal({
|
|||||||
mode,
|
mode,
|
||||||
variant = 'project',
|
variant = 'project',
|
||||||
task,
|
task,
|
||||||
defaultSection = 'HR',
|
defaultSection = '인사관리',
|
||||||
defaultCategory = '채용 운영',
|
defaultCategory = '채용 운영',
|
||||||
defaultQuarter = '2026-Q2',
|
defaultQuarter = '2026-Q2',
|
||||||
sectionOptions,
|
sectionOptions,
|
||||||
@@ -56,345 +32,53 @@ export function TaskModal({
|
|||||||
onClose,
|
onClose,
|
||||||
}: TaskModalProps) {
|
}: TaskModalProps) {
|
||||||
const isRoutine = variant === 'routine';
|
const isRoutine = variant === 'routine';
|
||||||
const toDateInput = (iso: string | null | undefined) => {
|
const [form, setForm] = useState<TaskFormData>(() =>
|
||||||
if (!iso) return '';
|
buildTaskFormState({ task, variant, defaultSection, defaultCategory, defaultQuarter }),
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
const [form, setForm] = useState<TaskFormData>({
|
|
||||||
title: task?.title ?? '',
|
|
||||||
section: task?.section ?? defaultSection,
|
|
||||||
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
|
|
||||||
tag: task?.tag ?? '',
|
|
||||||
taskType: isRoutine
|
|
||||||
? '기반업무'
|
|
||||||
: (task?.taskType ? normalizeTaskType(task.taskType) : '실행과제'),
|
|
||||||
status: task?.status ?? 'TODO',
|
|
||||||
progress: task?.progress ?? 0,
|
|
||||||
description: task?.description ?? '',
|
|
||||||
issueEntries: task ? parseIssueEntries(task) : [],
|
|
||||||
quarter: task?.quarter ?? defaultQuarter,
|
|
||||||
startDate: toDateInput(task?.startDate),
|
|
||||||
dueDate: toDateInput(task?.dueDate),
|
|
||||||
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
|
||||||
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleAssignee = (memberId: string) => {
|
|
||||||
setForm((prev) => {
|
|
||||||
const has = prev.assigneeMemberIds.includes(memberId);
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
assigneeMemberIds: has
|
|
||||||
? prev.assigneeMemberIds.filter((id) => id !== memberId)
|
|
||||||
: [...prev.assigneeMemberIds, memberId],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const set = <K extends keyof TaskFormData>(field: K, value: TaskFormData[K]) =>
|
|
||||||
setForm(prev => ({ ...prev, [field]: value }));
|
|
||||||
|
|
||||||
const updateIssueEntry = (id: string, patch: Partial<TaskIssueEntry>) => {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
issueEntries: prev.issueEntries.map((entry) =>
|
|
||||||
entry.id === id ? { ...entry, ...patch } : entry,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addIssueEntry = () => {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
issueEntries: [...prev.issueEntries, newIssueEntry()],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeIssueEntry = (id: string) => {
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
issueEntries: prev.issueEntries.filter((entry) => entry.id !== id),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload: TaskFormData = {
|
onSave({
|
||||||
...form,
|
...form,
|
||||||
taskType: isRoutine ? '기반업무' : '실행과제',
|
taskType: isRoutine ? '기반업무' : '실행과제',
|
||||||
};
|
});
|
||||||
onSave(payload);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div className="task-modal-overlay" onClick={onClose}>
|
||||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50"
|
<div className="task-modal-shell" onClick={(e) => e.stopPropagation()}>
|
||||||
onClick={onClose}
|
<div className="task-modal-head">
|
||||||
>
|
<h2 className="task-modal-title">
|
||||||
<div
|
|
||||||
className="bg-white rounded-2xl shadow-2xl w-[540px] max-h-[90vh] overflow-y-auto"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
|
||||||
<h2 className="text-2xl font-black text-gray-800">
|
|
||||||
{isRoutine
|
{isRoutine
|
||||||
? (mode === 'add' ? '✚ 상시업무 추가' : '✏ 상시업무 수정')
|
? mode === 'add'
|
||||||
: (mode === 'add' ? '✚ 프로젝트 추가' : '✏ 프로젝트 수정')}
|
? '상시업무 추가'
|
||||||
|
: '상시업무 수정'
|
||||||
|
: mode === 'add'
|
||||||
|
? '프로젝트 추가'
|
||||||
|
: '프로젝트 수정'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button type="button" onClick={onClose} className="task-modal-close" aria-label="닫기">
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="text-gray-400 hover:text-gray-600 text-2xl leading-none transition-colors"
|
|
||||||
>
|
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
<form onSubmit={handleSubmit} className="task-modal-body">
|
||||||
{/* 제목 */}
|
<TaskFormFields
|
||||||
<div>
|
variant={variant}
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">제목 *</label>
|
form={form}
|
||||||
<input
|
onChange={setForm}
|
||||||
required
|
sectionOptions={sectionOptions}
|
||||||
value={form.title}
|
teamMembers={teamMembers}
|
||||||
onChange={(e) => set('title', e.target.value)}
|
idPrefix={`modal-${mode}`}
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-lg outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
|
||||||
placeholder="업무 제목을 입력하세요"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 대분류 (상시업무) / 소속 부문 (프로젝트) */}
|
<div className="task-form-actions">
|
||||||
<div>
|
<button type="button" onClick={onClose} className="task-form-btn task-form-btn--ghost">
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">
|
|
||||||
{isRoutine ? '대분류' : '소속 부문'}
|
|
||||||
</label>
|
|
||||||
{isRoutine ? (
|
|
||||||
<select
|
|
||||||
value={form.category}
|
|
||||||
onChange={(e) => set('category', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-emerald-400 focus:ring-emerald-100"
|
|
||||||
>
|
|
||||||
{routineCategoryOptions().map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={form.section}
|
|
||||||
onChange={(e) => set('section', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:ring-2 transition bg-white focus:border-blue-400 focus:ring-blue-100"
|
|
||||||
>
|
|
||||||
{(sectionOptions ?? [
|
|
||||||
{ value: 'HR', label: 'HR' },
|
|
||||||
{ value: '운영관리', label: '운영관리' },
|
|
||||||
]).map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 상태 + 진행률 (프로젝트만) */}
|
|
||||||
{!isRoutine && (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">상태</label>
|
|
||||||
<select
|
|
||||||
value={form.status}
|
|
||||||
onChange={(e) => set('status', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition bg-white"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s.value} value={s.value}>{s.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">
|
|
||||||
진행률
|
|
||||||
<span className="ml-2 font-black text-gray-800">{form.progress}%</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={5}
|
|
||||||
value={form.progress}
|
|
||||||
onChange={(e) => set('progress', Number(e.target.value))}
|
|
||||||
className="flex-1 accent-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 상태 (상시업무) */}
|
|
||||||
{isRoutine && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">상태</label>
|
|
||||||
<select
|
|
||||||
value={form.status}
|
|
||||||
onChange={(e) => set('status', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<option key={s.value} value={s.value}>{s.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 내용 */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">내용</label>
|
|
||||||
<textarea
|
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => set('description', e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 text-base outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition"
|
|
||||||
placeholder="내용을 입력하세요"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PM + 담당자 */}
|
|
||||||
{teamMembers.length > 0 && (
|
|
||||||
<div className="space-y-3 rounded-xl border border-emerald-100 bg-emerald-50/40 p-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">PM</label>
|
|
||||||
<select
|
|
||||||
value={form.pmMemberId}
|
|
||||||
onChange={(e) => set('pmMemberId', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 transition bg-white"
|
|
||||||
>
|
|
||||||
<option value="">선택 안 함</option>
|
|
||||||
{teamMembers.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}{m.rank ? ` · ${m.rank}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">담당자 (복수 선택)</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{teamMembers.map((m) => {
|
|
||||||
const checked = form.assigneeMemberIds.includes(m.id);
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={m.id}
|
|
||||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border cursor-pointer select-none text-sm font-semibold transition ${
|
|
||||||
checked
|
|
||||||
? 'bg-emerald-600 text-white border-emerald-600'
|
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-emerald-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="sr-only"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleAssignee(m.id)}
|
|
||||||
/>
|
|
||||||
{m.name}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 프로젝트 기간 */}
|
|
||||||
{!isRoutine && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-500 mb-1.5">기간</label>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.startDate}
|
|
||||||
onChange={(e) => set('startDate', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={form.dueDate}
|
|
||||||
onChange={(e) => set('dueDate', e.target.value)}
|
|
||||||
className="w-full border border-gray-200 rounded-xl px-4 py-2.5 outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 transition"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이슈 메모 */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="text-sm font-bold text-gray-500">이슈 메모</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addIssueEntry}
|
|
||||||
className="text-xs font-bold text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition"
|
|
||||||
>
|
|
||||||
+ 이슈 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{form.issueEntries.length === 0 ? (
|
|
||||||
<p className="text-sm text-gray-400 rounded-xl border border-dashed border-gray-200 px-4 py-3 text-center">
|
|
||||||
등록된 이슈가 없습니다. 위 버튼으로 추가하세요.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{form.issueEntries.map((entry, index) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="rounded-xl border border-gray-200 bg-gray-50/60 p-3 space-y-2"
|
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
value={entry.text}
|
|
||||||
onChange={(e) => updateIssueEntry(entry.id, { text: e.target.value })}
|
|
||||||
rows={2}
|
|
||||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:border-blue-400 focus:ring-2 focus:ring-blue-100 resize-none transition bg-white"
|
|
||||||
placeholder={`[날짜] 이슈 내용 (${index + 1})`}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={entry.showOnCard}
|
|
||||||
onChange={(e) => updateIssueEntry(entry.id, { showOnCard: e.target.checked })}
|
|
||||||
className="w-4 h-4 accent-blue-500 cursor-pointer"
|
|
||||||
/>
|
|
||||||
<span className="text-xs font-semibold text-gray-500">카드 표시</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeIssueEntry(entry.id)}
|
|
||||||
className="text-xs font-bold text-red-500 hover:text-red-600 px-2 py-1 rounded-lg hover:bg-red-50 transition"
|
|
||||||
>
|
|
||||||
삭제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 버튼 */}
|
|
||||||
<div className="flex justify-end gap-2 pt-2 pb-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-semibold hover:bg-gray-50 transition"
|
|
||||||
>
|
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={`px-6 py-2.5 rounded-xl text-white font-bold transition ${
|
className={`task-form-btn task-form-btn--primary${isRoutine ? ' is-routine' : ''}`}
|
||||||
isRoutine ? 'bg-emerald-700 hover:bg-emerald-800' : 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{mode === 'add' ? '추가하기' : '저장하기'}
|
{mode === 'add' ? '추가하기' : '저장하기'}
|
||||||
</button>
|
</button>
|
||||||
@@ -402,6 +86,6 @@ export function TaskModal({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import {
|
import {
|
||||||
QUARTER_RANGE_LABELS,
|
QUARTER_RANGE_LABELS,
|
||||||
buildMonthWeekRows,
|
buildMonthWeekRows,
|
||||||
dateToQuarter,
|
|
||||||
isSameDay,
|
|
||||||
isSameWeek,
|
isSameWeek,
|
||||||
quarterEndDate,
|
|
||||||
startOfDay,
|
startOfDay,
|
||||||
startOfWeekMonday,
|
startOfWeekMonday,
|
||||||
toIsoDate,
|
toIsoDate,
|
||||||
@@ -16,32 +13,40 @@ import {
|
|||||||
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
|
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'];
|
||||||
|
|
||||||
interface BoardCalendarPopoverProps {
|
interface BoardCalendarPopoverProps {
|
||||||
referenceDate: Date;
|
selectedQuarter: string;
|
||||||
onChange: (d: Date) => void;
|
referenceWeekMonday: Date;
|
||||||
|
weekLensActive: boolean;
|
||||||
|
onSelectWeek: (d: Date) => void;
|
||||||
|
onSelectQuarter: (quarterKey: string) => void;
|
||||||
|
onReturnToCurrentQuarter: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
anchorRef: RefObject<HTMLElement | null>;
|
anchorRef: RefObject<HTMLElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardCalendarPopover({
|
export function BoardCalendarPopover({
|
||||||
referenceDate,
|
selectedQuarter,
|
||||||
onChange,
|
referenceWeekMonday,
|
||||||
|
weekLensActive,
|
||||||
|
onSelectWeek,
|
||||||
|
onSelectQuarter,
|
||||||
|
onReturnToCurrentQuarter,
|
||||||
onClose,
|
onClose,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
}: BoardCalendarPopoverProps) {
|
}: BoardCalendarPopoverProps) {
|
||||||
const [viewYear, setViewYear] = useState(referenceDate.getFullYear());
|
const anchorMonday = startOfWeekMonday(referenceWeekMonday);
|
||||||
const [viewMonth, setViewMonth] = useState(referenceDate.getMonth());
|
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||||
|
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||||
|
|
||||||
const weekRows = useMemo(
|
const weekRows = useMemo(
|
||||||
() => buildMonthWeekRows(viewYear, viewMonth),
|
() => buildMonthWeekRows(viewYear, viewMonth),
|
||||||
[viewYear, viewMonth],
|
[viewYear, viewMonth],
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeQuarterKey = dateToQuarter(referenceDate);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setViewYear(referenceDate.getFullYear());
|
if (!weekLensActive) return;
|
||||||
setViewMonth(referenceDate.getMonth());
|
setViewYear(anchorMonday.getFullYear());
|
||||||
}, [referenceDate]);
|
setViewMonth(anchorMonday.getMonth());
|
||||||
|
}, [weekLensActive, anchorMonday]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onPointerDown = (e: MouseEvent) => {
|
const onPointerDown = (e: MouseEvent) => {
|
||||||
@@ -74,18 +79,11 @@ export function BoardCalendarPopover({
|
|||||||
setViewMonth(d.getMonth());
|
setViewMonth(d.getMonth());
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickDate = (d: Date) => {
|
|
||||||
onChange(startOfDay(d));
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickQuarter = (q: 1 | 2 | 3 | 4) => {
|
|
||||||
onChange(quarterEndDate(`${viewYear}-Q${q}`));
|
|
||||||
};
|
|
||||||
|
|
||||||
const today = startOfDay(new Date());
|
const today = startOfDay(new Date());
|
||||||
|
const todayMonday = startOfWeekMonday(today);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="기준일 선택">
|
<div id="board-calendar-popover" className="board-calendar-popover" style={style} role="dialog" aria-label="주차 · 분기 선택">
|
||||||
<div className="board-calendar-popover-head">
|
<div className="board-calendar-popover-head">
|
||||||
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(-1)} aria-label="이전 달">‹</button>
|
<button type="button" className="board-calendar-nav" onClick={() => shiftMonth(-1)} aria-label="이전 달">‹</button>
|
||||||
<span className="board-calendar-month">{viewYear}년 {viewMonth + 1}월</span>
|
<span className="board-calendar-month">{viewYear}년 {viewMonth + 1}월</span>
|
||||||
@@ -95,13 +93,13 @@ export function BoardCalendarPopover({
|
|||||||
<div className="board-calendar-quarter-row">
|
<div className="board-calendar-quarter-row">
|
||||||
{([1, 2, 3, 4] as const).map((q) => {
|
{([1, 2, 3, 4] as const).map((q) => {
|
||||||
const key = `${viewYear}-Q${q}`;
|
const key = `${viewYear}-Q${q}`;
|
||||||
const selected = activeQuarterKey === key;
|
const selected = !weekLensActive && selectedQuarter === key;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
className={`board-calendar-quarter-chip${selected ? ' is-selected' : ''}`}
|
className={`board-calendar-quarter-chip${selected ? ' is-selected' : ''}`}
|
||||||
onClick={() => pickQuarter(q)}
|
onClick={() => onSelectQuarter(key)}
|
||||||
>
|
>
|
||||||
<span className="board-calendar-quarter-chip-title">{q}분기</span>
|
<span className="board-calendar-quarter-chip-title">{q}분기</span>
|
||||||
<span className="board-calendar-quarter-chip-range">{QUARTER_RANGE_LABELS[q - 1]}</span>
|
<span className="board-calendar-quarter-chip-range">{QUARTER_RANGE_LABELS[q - 1]}</span>
|
||||||
@@ -122,36 +120,38 @@ export function BoardCalendarPopover({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{weekRows.map((row) => {
|
{weekRows.map((row) => {
|
||||||
const weekSelected = isSameWeek(referenceDate, row.monday);
|
const weekSelected = weekLensActive && isSameWeek(anchorMonday, row.monday);
|
||||||
return (
|
return (
|
||||||
<tr key={toIsoDate(row.monday)} className={weekSelected ? 'is-selected-week' : undefined}>
|
<tr
|
||||||
<td className="board-calendar-grid-week-col">
|
key={toIsoDate(row.monday)}
|
||||||
<button
|
className={weekSelected ? 'is-selected-week' : undefined}
|
||||||
type="button"
|
onClick={() => onSelectWeek(row.monday)}
|
||||||
className="board-calendar-week-label-btn"
|
role="button"
|
||||||
onClick={() => pickDate(startOfWeekMonday(row.monday))}
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelectWeek(row.monday);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{row.label}
|
<td className="board-calendar-grid-week-col">
|
||||||
</button>
|
<span className="board-calendar-week-label-btn">{row.label}</span>
|
||||||
</td>
|
</td>
|
||||||
{row.days.map((day) => {
|
{row.days.map((day) => {
|
||||||
const inMonth = day.getMonth() === viewMonth;
|
const inMonth = day.getMonth() === viewMonth;
|
||||||
const isRef = isSameDay(day, referenceDate);
|
const isToday = startOfDay(day).getTime() === today.getTime();
|
||||||
const isToday = isSameDay(day, today);
|
|
||||||
return (
|
return (
|
||||||
<td key={toIsoDate(day)}>
|
<td key={toIsoDate(day)}>
|
||||||
<button
|
<span
|
||||||
type="button"
|
|
||||||
className={[
|
className={[
|
||||||
'board-calendar-day-btn',
|
'board-calendar-day-cell',
|
||||||
!inMonth ? 'is-outside' : '',
|
!inMonth ? 'is-outside' : '',
|
||||||
isRef ? 'is-ref' : '',
|
|
||||||
isToday ? 'is-today' : '',
|
isToday ? 'is-today' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => pickDate(day)}
|
|
||||||
>
|
>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</button>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -163,8 +163,8 @@ export function BoardCalendarPopover({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="board-calendar-foot">
|
<div className="board-calendar-foot">
|
||||||
<button type="button" onClick={() => pickDate(startOfWeekMonday(today))}>이번 주</button>
|
<button type="button" onClick={() => onSelectWeek(todayMonday)}>이번 주</button>
|
||||||
<button type="button" onClick={() => pickDate(today)}>이번 분기</button>
|
<button type="button" onClick={onReturnToCurrentQuarter}>이번 분기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { formatReferenceSummary } from '../../lib/boardCalendar';
|
import { formatBoardCalendarPill } from '../../lib/boardCalendar';
|
||||||
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
import { isDetailWindowOpen } from '../../lib/dualMonitor';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -38,9 +38,15 @@ interface DashboardHeaderProps {
|
|||||||
|
|
||||||
quarter: string;
|
quarter: string;
|
||||||
|
|
||||||
referenceDate: Date;
|
referenceWeekMonday: Date;
|
||||||
|
|
||||||
onReferenceDateChange: (d: Date) => void;
|
weekLensActive: boolean;
|
||||||
|
|
||||||
|
onSelectWeek: (d: Date) => void;
|
||||||
|
|
||||||
|
onSelectQuarter: (quarterKey: string) => void;
|
||||||
|
|
||||||
|
onReturnToCurrentQuarter: () => void;
|
||||||
|
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
|
|
||||||
@@ -90,9 +96,15 @@ export function DashboardHeader({
|
|||||||
|
|
||||||
quarter,
|
quarter,
|
||||||
|
|
||||||
referenceDate,
|
referenceWeekMonday,
|
||||||
|
|
||||||
onReferenceDateChange,
|
weekLensActive,
|
||||||
|
|
||||||
|
onSelectWeek,
|
||||||
|
|
||||||
|
onSelectQuarter,
|
||||||
|
|
||||||
|
onReturnToCurrentQuarter,
|
||||||
|
|
||||||
stats,
|
stats,
|
||||||
|
|
||||||
@@ -304,12 +316,14 @@ export function DashboardHeader({
|
|||||||
|
|
||||||
<div className="side-right-actions shrink-0">
|
<div className="side-right-actions shrink-0">
|
||||||
<div className="header-calendar-slot">
|
<div className="header-calendar-slot">
|
||||||
<span className="board-calendar-ref-text">{formatReferenceSummary(referenceDate)}</span>
|
<span className="board-calendar-ref-text">
|
||||||
|
{formatBoardCalendarPill(quarter, weekLensActive, referenceWeekMonday)}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
ref={calendarBtnRef}
|
ref={calendarBtnRef}
|
||||||
type="button"
|
type="button"
|
||||||
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
|
className={`header-calendar-btn-new${calendarOpen ? ' active' : ''}`}
|
||||||
title="기준일 · 분기 선택"
|
title="주차 · 분기 선택"
|
||||||
aria-expanded={calendarOpen}
|
aria-expanded={calendarOpen}
|
||||||
aria-label="캘린더 열기"
|
aria-label="캘린더 열기"
|
||||||
onClick={() => setCalendarOpen((open) => !open)}
|
onClick={() => setCalendarOpen((open) => !open)}
|
||||||
@@ -318,10 +332,12 @@ export function DashboardHeader({
|
|||||||
</button>
|
</button>
|
||||||
{calendarOpen && (
|
{calendarOpen && (
|
||||||
<BoardCalendarPopover
|
<BoardCalendarPopover
|
||||||
referenceDate={referenceDate}
|
selectedQuarter={quarter}
|
||||||
onChange={(d) => {
|
referenceWeekMonday={referenceWeekMonday}
|
||||||
onReferenceDateChange(d);
|
weekLensActive={weekLensActive}
|
||||||
}}
|
onSelectWeek={onSelectWeek}
|
||||||
|
onSelectQuarter={onSelectQuarter}
|
||||||
|
onReturnToCurrentQuarter={onReturnToCurrentQuarter}
|
||||||
onClose={() => setCalendarOpen(false)}
|
onClose={() => setCalendarOpen(false)}
|
||||||
anchorRef={calendarBtnRef}
|
anchorRef={calendarBtnRef}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DeptIcon } from './DeptIcon';
|
|||||||
import { DeptProjectList } from './DeptProjectList';
|
import { DeptProjectList } from './DeptProjectList';
|
||||||
import { ContextMenu } from '../common/ContextMenu';
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
import { TaskModal } from '../common/TaskModal';
|
import { TaskModal } from '../common/TaskModal';
|
||||||
import type { TaskFormData } from '../common/TaskModal';
|
import type { TaskFormData } from '../../lib/taskFormState';
|
||||||
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
|
import { projectFormToApiPayload } from '../../lib/taskFormPayload';
|
||||||
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import type { Task, TeamMember } from '../../types';
|
||||||
@@ -40,6 +40,7 @@ interface DepartmentColumnProps {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
orderedIds: string[];
|
orderedIds: string[];
|
||||||
quarter: string;
|
quarter: string;
|
||||||
|
referenceWeekMonday?: Date;
|
||||||
onSelectTask?: (task: Task) => void;
|
onSelectTask?: (task: Task) => void;
|
||||||
sectionOptions?: { value: string; label: string }[];
|
sectionOptions?: { value: string; label: string }[];
|
||||||
teamMembers?: TeamMember[];
|
teamMembers?: TeamMember[];
|
||||||
@@ -114,6 +115,7 @@ export function DepartmentColumn({
|
|||||||
tasks,
|
tasks,
|
||||||
orderedIds,
|
orderedIds,
|
||||||
quarter,
|
quarter,
|
||||||
|
referenceWeekMonday,
|
||||||
onSelectTask,
|
onSelectTask,
|
||||||
sectionOptions: externalSectionOptions,
|
sectionOptions: externalSectionOptions,
|
||||||
teamMembers = [],
|
teamMembers = [],
|
||||||
@@ -169,7 +171,7 @@ export function DepartmentColumn({
|
|||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
|
mutationFn: (data: Partial<Task>) => apiClient.post('/tasks', data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => invalidateTaskCaches(queryClient),
|
||||||
});
|
});
|
||||||
|
|
||||||
const patch = useMutation({
|
const patch = useMutation({
|
||||||
@@ -180,7 +182,7 @@ export function DepartmentColumn({
|
|||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: (_data, id) => invalidateTaskCaches(queryClient, id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleColumnContextMenuCapture = (e: React.MouseEvent) => {
|
const handleColumnContextMenuCapture = (e: React.MouseEvent) => {
|
||||||
@@ -286,6 +288,7 @@ export function DepartmentColumn({
|
|||||||
task={task}
|
task={task}
|
||||||
variant="project"
|
variant="project"
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
|
referenceWeekMonday={referenceWeekMonday}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { quarterDateBounds, sortScheduleItems, todayIso } from '../../lib/hubSch
|
|||||||
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
import { HubScheduleCarousel } from './HubScheduleCarousel';
|
||||||
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
|
import { HubRoutineFocusPanel } from './HubRoutineFocusPanel';
|
||||||
import { ContextMenu } from '../common/ContextMenu';
|
import { ContextMenu } from '../common/ContextMenu';
|
||||||
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
|
import { routineCategoryShellPayload } from '../../lib/taskFormPayload';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -200,7 +201,7 @@ export function HubColumn({
|
|||||||
'/tasks',
|
'/tasks',
|
||||||
routineCategoryShellPayload(label, quarter),
|
routineCategoryShellPayload(label, quarter),
|
||||||
);
|
);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
await invalidateTaskCaches(queryClient, created.id);
|
||||||
onSelectRoutine?.(created);
|
onSelectRoutine?.(created);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
|
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
|
||||||
@@ -223,7 +224,7 @@ export function HubColumn({
|
|||||||
'/tasks',
|
'/tasks',
|
||||||
routineCategoryShellPayload(label, quarter),
|
routineCategoryShellPayload(label, quarter),
|
||||||
);
|
);
|
||||||
await queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
await invalidateTaskCaches(queryClient, created.id);
|
||||||
onSelectRoutineMilestone?.(created.id, milestoneId);
|
onSelectRoutineMilestone?.(created.id, milestoneId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
|
alert(getApiErrorMessage(err, `"${label}" 업무를 준비하지 못했습니다.`));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { HubNavChevron } from '../common/HubNavChevron';
|
|
||||||
import { HubFocusTaskList } from './HubFocusTaskList';
|
import { HubFocusTaskList } from './HubFocusTaskList';
|
||||||
import { ROUTINE_CATEGORIES } from '../../lib/routineCategories';
|
import { ROUTINE_CATEGORIES } from '../../lib/routineCategories';
|
||||||
import type { RoutineCategoryFocus } from '../../hooks/useRoutineCategoryMilestones';
|
import type { RoutineCategoryFocus } from '../../hooks/useRoutineCategoryMilestones';
|
||||||
@@ -18,16 +17,6 @@ export function HubRoutineFocusPanel({
|
|||||||
}: HubRoutineFocusPanelProps) {
|
}: HubRoutineFocusPanelProps) {
|
||||||
const safeIndex = Math.max(0, Math.min(activeIndex, ROUTINE_CATEGORIES.length - 1));
|
const safeIndex = Math.max(0, Math.min(activeIndex, ROUTINE_CATEGORIES.length - 1));
|
||||||
const active = categories[safeIndex];
|
const active = categories[safeIndex];
|
||||||
const canPrev = safeIndex > 0;
|
|
||||||
const canNext = safeIndex < ROUTINE_CATEGORIES.length - 1;
|
|
||||||
|
|
||||||
const stepPrev = () => {
|
|
||||||
if (canPrev) onActiveIndexChange(safeIndex - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepNext = () => {
|
|
||||||
if (canNext) onActiveIndexChange(safeIndex + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hub-routine-focus">
|
<div className="hub-routine-focus">
|
||||||
@@ -47,32 +36,12 @@ export function HubRoutineFocusPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hub-routine-focus-body">
|
<div className="hub-routine-focus-body">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hub-routine-focus-nav hub-routine-focus-nav--prev"
|
|
||||||
disabled={!canPrev}
|
|
||||||
onClick={stepPrev}
|
|
||||||
aria-label="이전 대분류"
|
|
||||||
>
|
|
||||||
<HubNavChevron direction="prev" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<HubFocusTaskList
|
<HubFocusTaskList
|
||||||
milestones={active?.milestones ?? []}
|
milestones={active?.milestones ?? []}
|
||||||
isLoading={!!active?.isLoading}
|
isLoading={!!active?.isLoading}
|
||||||
categoryKey={ROUTINE_CATEGORIES[safeIndex]}
|
categoryKey={ROUTINE_CATEGORIES[safeIndex]}
|
||||||
onSelectMilestone={onSelectMilestone}
|
onSelectMilestone={onSelectMilestone}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hub-routine-focus-nav hub-routine-focus-nav--next"
|
|
||||||
disabled={!canNext}
|
|
||||||
onClick={stepNext}
|
|
||||||
aria-label="다음 대분류"
|
|
||||||
>
|
|
||||||
<HubNavChevron direction="next" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { HubScheduleItem } from '../../lib/hubConfig';
|
import type { HubScheduleItem } from '../../lib/hubConfig';
|
||||||
import { HubNavChevron } from '../common/HubNavChevron';
|
|
||||||
import {
|
import {
|
||||||
findScheduleIndexForToday,
|
findScheduleIndexForToday,
|
||||||
formatScheduleDateParts,
|
formatScheduleDateParts,
|
||||||
@@ -11,15 +10,28 @@ import {
|
|||||||
} from '../../lib/hubSchedule';
|
} from '../../lib/hubSchedule';
|
||||||
|
|
||||||
const VISIBLE_COUNT = 3;
|
const VISIBLE_COUNT = 3;
|
||||||
|
const ROW_STEP_PX = 46;
|
||||||
|
const WHEEL_THRESHOLD = 18;
|
||||||
|
const WHEEL_COOLDOWN_MS = 140;
|
||||||
|
|
||||||
interface HubScheduleCarouselProps {
|
interface HubScheduleCarouselProps {
|
||||||
items: HubScheduleItem[];
|
items: HubScheduleItem[];
|
||||||
/** 기준일 — 없으면 오늘 */
|
|
||||||
focusDate?: Date;
|
focusDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function slotOpacity(slot: number): number {
|
||||||
|
if (slot === 0) return 0.4;
|
||||||
|
if (slot === 1) return 1;
|
||||||
|
if (slot === 2) return 0.88;
|
||||||
|
return 0.28;
|
||||||
|
}
|
||||||
|
|
||||||
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
|
export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const wheelAccRef = useRef(0);
|
||||||
|
const wheelCooldownRef = useRef(false);
|
||||||
|
const dragRef = useRef<{ y: number; scroll: number } | null>(null);
|
||||||
|
|
||||||
const focus = focusDate ?? new Date();
|
const focus = focusDate ?? new Date();
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(
|
||||||
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
() => sortScheduleItems(items.filter((i) => i.date && i.text.trim())),
|
||||||
@@ -31,65 +43,134 @@ export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselPro
|
|||||||
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
|
() => scheduleWindowStart(sorted.length, focusIndex, VISIBLE_COUNT),
|
||||||
[sorted.length, focusIndex],
|
[sorted.length, focusIndex],
|
||||||
);
|
);
|
||||||
const [startIndex, setStartIndex] = useState(initialStart);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setStartIndex(initialStart);
|
|
||||||
}, [initialStart, sorted.length]);
|
|
||||||
|
|
||||||
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
|
const maxStart = Math.max(0, sorted.length - VISIBLE_COUNT);
|
||||||
const paged = sorted.length > VISIBLE_COUNT;
|
const scrollable = sorted.length > VISIBLE_COUNT;
|
||||||
const canPrev = startIndex > 0;
|
|
||||||
const canNext = startIndex < maxStart;
|
|
||||||
|
|
||||||
const stepPrev = () => setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
|
const clampScrollPx = useCallback(
|
||||||
const stepNext = () => setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
|
(px: number) => Math.min(maxStart * ROW_STEP_PX, Math.max(0, px)),
|
||||||
|
[maxStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
const snapScrollPx = useCallback(
|
||||||
|
(px: number) => {
|
||||||
|
const idx = Math.round(px / ROW_STEP_PX);
|
||||||
|
return clampScrollPx(idx * ROW_STEP_PX);
|
||||||
|
},
|
||||||
|
[clampScrollPx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [scrollPx, setScrollPx] = useState(() => initialStart * ROW_STEP_PX);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = rootRef.current;
|
setScrollPx(initialStart * ROW_STEP_PX);
|
||||||
if (!root || !paged) return;
|
}, [initialStart]);
|
||||||
|
|
||||||
|
const startIndex = Math.round(scrollPx / ROW_STEP_PX);
|
||||||
|
|
||||||
|
const stepBy = useCallback(
|
||||||
|
(delta: number) => {
|
||||||
|
if (delta === 0) return;
|
||||||
|
setScrollPx((prev) => snapScrollPx(prev + delta * ROW_STEP_PX));
|
||||||
|
},
|
||||||
|
[snapScrollPx],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (!viewport || !scrollable) return;
|
||||||
|
|
||||||
const onWheel = (event: WheelEvent) => {
|
const onWheel = (event: WheelEvent) => {
|
||||||
if (Math.abs(event.deltaY) < 1) return;
|
if (Math.abs(event.deltaY) < 1) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.deltaY > 0) {
|
if (wheelCooldownRef.current || dragRef.current) return;
|
||||||
setStartIndex((i) => Math.min(maxStart, i + VISIBLE_COUNT));
|
|
||||||
} else {
|
wheelAccRef.current += event.deltaY;
|
||||||
setStartIndex((i) => Math.max(0, i - VISIBLE_COUNT));
|
if (Math.abs(wheelAccRef.current) < WHEEL_THRESHOLD) return;
|
||||||
}
|
|
||||||
|
const dir = wheelAccRef.current > 0 ? 1 : -1;
|
||||||
|
wheelAccRef.current = 0;
|
||||||
|
wheelCooldownRef.current = true;
|
||||||
|
stepBy(dir);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
wheelCooldownRef.current = false;
|
||||||
|
}, WHEEL_COOLDOWN_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
root.addEventListener('wheel', onWheel, { passive: false });
|
viewport.addEventListener('wheel', onWheel, { passive: false });
|
||||||
return () => root.removeEventListener('wheel', onWheel);
|
return () => viewport.removeEventListener('wheel', onWheel);
|
||||||
}, [paged, maxStart]);
|
}, [scrollable, stepBy]);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.button !== 0 || !scrollable) return;
|
||||||
|
dragRef.current = { y: event.clientY, scroll: scrollPx };
|
||||||
|
setIsDragging(true);
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
[scrollPx, scrollable],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
const deltaY = event.clientY - dragRef.current.y;
|
||||||
|
setScrollPx(clampScrollPx(dragRef.current.scroll - deltaY));
|
||||||
|
},
|
||||||
|
[clampScrollPx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const endDrag = useCallback(
|
||||||
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragRef.current) return;
|
||||||
|
dragRef.current = null;
|
||||||
|
setIsDragging(false);
|
||||||
|
setScrollPx((prev) => snapScrollPx(prev));
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||||
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[snapScrollPx],
|
||||||
|
);
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="hub-schedule-viewport hub-schedule-viewport--empty">
|
<div className="hub-schedule-wheel-viewport hub-schedule-wheel-viewport--empty">
|
||||||
<p className="board-project-desc hub-schedule-empty">등록된 일정 없음</p>
|
<p className="board-project-desc hub-schedule-empty">등록된 일정 없음</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = sorted.slice(startIndex, startIndex + VISIBLE_COUNT);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="hub-schedule-wheel-wrap">
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={viewportRef}
|
||||||
className={`hub-schedule-carousel${paged ? ' is-paged' : ''}`}
|
className={`hub-schedule-wheel-viewport${isDragging ? ' is-dragging' : ''}${scrollable ? ' is-scrollable' : ''}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
>
|
>
|
||||||
<div className="hub-schedule-viewport">
|
<div
|
||||||
<ul className="hub-schedule-list hub-list">
|
className={`hub-schedule-wheel-track${isDragging ? ' is-dragging' : ''}`}
|
||||||
{visible.map((item) => {
|
style={{ transform: `translateY(-${scrollPx}px)` }}
|
||||||
|
>
|
||||||
|
{sorted.map((item, index) => {
|
||||||
const past = isSchedulePast(item.date, focus);
|
const past = isSchedulePast(item.date, focus);
|
||||||
const today = isScheduleToday(item.date, focus);
|
const today = isScheduleToday(item.date, focus);
|
||||||
const dateParts = formatScheduleDateParts(item.date);
|
const dateParts = formatScheduleDateParts(item.date);
|
||||||
|
const inWindow = index >= startIndex && index < startIndex + VISIBLE_COUNT;
|
||||||
|
const slot = inWindow ? index - startIndex : -1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`hub-schedule-item${past ? ' hub-schedule-item--past' : ''}${today ? ' hub-schedule-item--today' : ''}`}
|
className={`hub-schedule-wheel-row${past ? ' is-past' : ''}${today ? ' is-today' : ''}${inWindow ? ` is-slot-${slot}` : ''}`}
|
||||||
|
style={inWindow ? { opacity: slotOpacity(slot) } : undefined}
|
||||||
>
|
>
|
||||||
<span className="hub-schedule-date board-project-desc">
|
<span className="hub-schedule-wheel-date">
|
||||||
{dateParts ? (
|
{dateParts ? (
|
||||||
<>
|
<>
|
||||||
<span className="hub-schedule-date-month">{dateParts.month}</span>
|
<span className="hub-schedule-date-month">{dateParts.month}</span>
|
||||||
@@ -99,36 +180,12 @@ export function HubScheduleCarousel({ items, focusDate }: HubScheduleCarouselPro
|
|||||||
item.date
|
item.date
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="board-project-desc">{item.text}</span>
|
<span className="hub-schedule-wheel-text">{item.text}</span>
|
||||||
</li>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{canPrev && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hub-schedule-nav hub-schedule-nav--prev"
|
|
||||||
onClick={stepPrev}
|
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
|
||||||
aria-label="이전 일정"
|
|
||||||
>
|
|
||||||
<HubNavChevron direction="prev" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canNext && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hub-schedule-nav hub-schedule-nav--next"
|
|
||||||
onClick={stepNext}
|
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
|
||||||
aria-label="다음 일정"
|
|
||||||
>
|
|
||||||
<HubNavChevron direction="next" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef } from 'react';
|
import { useMemo, useRef } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { DraggableAttributes } from '@dnd-kit/core';
|
import type { DraggableAttributes } from '@dnd-kit/core';
|
||||||
@@ -7,6 +7,7 @@ import type { Task } from '../../types';
|
|||||||
import { DonutGauge } from './DonutGauge';
|
import { DonutGauge } from './DonutGauge';
|
||||||
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
|
import { getProjectTitleStatusClass } from '../../lib/taskStatusVisual';
|
||||||
import { getVisibleIssueEntries } from '../../lib/taskIssues';
|
import { getVisibleIssueEntries } from '../../lib/taskIssues';
|
||||||
|
import { resolveTaskWeekFocus } from '../../lib/milestoneWeekFocus';
|
||||||
|
|
||||||
function fmtDate(iso: string | null | undefined): string {
|
function fmtDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
@@ -21,6 +22,15 @@ function fmtDateRange(task: Task): string {
|
|||||||
return `${start} ~ ${end}`;
|
return `${start} ~ ${end}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overviewTextForCard(text: string | null | undefined): string {
|
||||||
|
if (!text?.trim()) return '';
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.replace(/^[•·\-]\s*/, '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function firstDescriptionLine(text: string | null | undefined): string {
|
function firstDescriptionLine(text: string | null | undefined): string {
|
||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
|
const line = text.split('\n').map((l) => l.replace(/^[•·\-]\s*/, '').trim()).find(Boolean);
|
||||||
@@ -33,12 +43,14 @@ export function SortableTaskCard({
|
|||||||
task,
|
task,
|
||||||
variant = 'project',
|
variant = 'project',
|
||||||
onSelect,
|
onSelect,
|
||||||
|
referenceWeekMonday,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
variant?: 'project' | 'routine';
|
variant?: 'project' | 'routine';
|
||||||
sectionOptions?: SectionOption[];
|
sectionOptions?: SectionOption[];
|
||||||
accent?: string;
|
accent?: string;
|
||||||
onSelect?: (task: Task) => void;
|
onSelect?: (task: Task) => void;
|
||||||
|
referenceWeekMonday?: Date;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||||
const pointerStart = useRef<{ x: number; y: number } | null>(null);
|
const pointerStart = useRef<{ x: number; y: number } | null>(null);
|
||||||
@@ -63,6 +75,7 @@ export function SortableTaskCard({
|
|||||||
<TaskCard
|
<TaskCard
|
||||||
task={task}
|
task={task}
|
||||||
variant={variant}
|
variant={variant}
|
||||||
|
referenceWeekMonday={referenceWeekMonday}
|
||||||
dragRef={setNodeRef}
|
dragRef={setNodeRef}
|
||||||
dragStyle={{
|
dragStyle={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -80,6 +93,7 @@ export function SortableTaskCard({
|
|||||||
export function TaskCard({
|
export function TaskCard({
|
||||||
task,
|
task,
|
||||||
variant = 'project',
|
variant = 'project',
|
||||||
|
referenceWeekMonday,
|
||||||
dragRef,
|
dragRef,
|
||||||
dragStyle,
|
dragStyle,
|
||||||
dragAttributes,
|
dragAttributes,
|
||||||
@@ -89,6 +103,7 @@ export function TaskCard({
|
|||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
variant?: 'project' | 'routine';
|
variant?: 'project' | 'routine';
|
||||||
|
referenceWeekMonday?: Date;
|
||||||
dragRef?: (node: HTMLElement | null) => void;
|
dragRef?: (node: HTMLElement | null) => void;
|
||||||
dragStyle?: React.CSSProperties;
|
dragStyle?: React.CSSProperties;
|
||||||
dragAttributes?: DraggableAttributes;
|
dragAttributes?: DraggableAttributes;
|
||||||
@@ -125,9 +140,22 @@ export function TaskCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dateRange = fmtDateRange(task);
|
const dateRange = fmtDateRange(task);
|
||||||
const descLine = task.showDescription ? firstDescriptionLine(task.description) : '';
|
const overviewText = task.showDescription ? overviewTextForCard(task.description) : '';
|
||||||
const showProgress = task.showProgress !== false;
|
const showProgress = task.showProgress !== false;
|
||||||
const visibleIssues = getVisibleIssueEntries(task);
|
const issueVisibility = referenceWeekMonday ? { weekMonday: referenceWeekMonday } : undefined;
|
||||||
|
const visibleIssues = getVisibleIssueEntries(task, issueVisibility);
|
||||||
|
const hasVisibleIssue = visibleIssues.length > 0;
|
||||||
|
|
||||||
|
const weekFocus = useMemo(
|
||||||
|
() => (referenceWeekMonday ? resolveTaskWeekFocus(task, referenceWeekMonday) : null),
|
||||||
|
[task, referenceWeekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressTask = useMemo(() => {
|
||||||
|
if (!weekFocus) return task;
|
||||||
|
if (weekFocus.progress >= 100) return { status: 'DONE' as const, progress: 100 };
|
||||||
|
return { status: 'IN_PROGRESS' as const, progress: weekFocus.progress };
|
||||||
|
}, [task, weekFocus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
@@ -141,21 +169,44 @@ export function TaskCard({
|
|||||||
>
|
>
|
||||||
<div className="project-sub-body">
|
<div className="project-sub-body">
|
||||||
<div className="project-fields">
|
<div className="project-fields">
|
||||||
<div className={`project-sub-title ${getProjectTitleStatusClass(task)}`}>
|
<div className={`project-sub-title ${getProjectTitleStatusClass(task, issueVisibility)}`}>
|
||||||
<span className="project-sub-title-text">{task.title}</span>
|
<span className="project-sub-title-text">{task.title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{referenceWeekMonday ? (
|
||||||
|
weekFocus ? (
|
||||||
|
<>
|
||||||
|
<div className="project-field project-field--week-focus">
|
||||||
|
<span className="project-field-label">진행 단계</span>
|
||||||
|
<span className="project-field-value">{weekFocus.milestoneTitle}</span>
|
||||||
|
</div>
|
||||||
|
<div className="project-field project-field--week-period">
|
||||||
|
<span className="project-field-label">수행 기간</span>
|
||||||
|
<span className="project-field-value">{weekFocus.periodLabel}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="project-field project-field--week-empty">
|
||||||
|
<span className="project-field-value">해당 주 업무 없음</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{dateRange && (
|
{dateRange && (
|
||||||
<div className="project-field">
|
<div className="project-field">
|
||||||
<span className="project-field-label">수행 기간</span>
|
<span className="project-field-label">수행 기간</span>
|
||||||
<span className="project-field-value">{dateRange}</span>
|
<span className="project-field-value">{dateRange}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{descLine && (
|
{overviewText && (
|
||||||
<div className="project-field">
|
<div
|
||||||
<span className="project-field-label">주요 내용</span>
|
className={`project-field project-field--overview${hasVisibleIssue ? '' : ' project-field--overview-expanded'}`}
|
||||||
<span className="project-field-value">{descLine}</span>
|
>
|
||||||
|
<span className="project-field-label">개요</span>
|
||||||
|
<span className="project-field-value">{overviewText}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{visibleIssues.map((entry) => (
|
{visibleIssues.map((entry) => (
|
||||||
<div key={entry.id} className="project-field project-field--issue">
|
<div key={entry.id} className="project-field project-field--issue">
|
||||||
<span className="project-issue-icon" aria-label="이슈" title="이슈">
|
<span className="project-issue-icon" aria-label="이슈" title="이슈">
|
||||||
@@ -167,18 +218,12 @@ export function TaskCard({
|
|||||||
<span className="project-field-value project-field-value--issue">{entry.text}</span>
|
<span className="project-field-value project-field-value--issue">{entry.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{visibleIssues.length === 0 && (
|
|
||||||
<div className="project-field project-field--issue project-field--issue-reserved" aria-hidden="true">
|
|
||||||
<span className="project-issue-icon" />
|
|
||||||
<span className="project-field-value project-field-value--issue"> </span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{showProgress && (
|
{showProgress && (
|
||||||
<>
|
<>
|
||||||
<div className="project-sub-divider" aria-hidden="true" />
|
<div className="project-sub-divider" aria-hidden="true" />
|
||||||
<div className="progress-col">
|
<div className="progress-col">
|
||||||
<DonutGauge task={task} />
|
<DonutGauge task={progressTask} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
231
frontend/src/components/dashboard/TaskFeedbackSection.tsx
Normal file
231
frontend/src/components/dashboard/TaskFeedbackSection.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||||
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
|
import type { Milestone, TaskDetail } from '../../types';
|
||||||
|
|
||||||
|
export function TaskFeedbackSection({
|
||||||
|
taskId,
|
||||||
|
milestones,
|
||||||
|
details,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
milestones: Milestone[];
|
||||||
|
details: TaskDetail[];
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [addMilestoneId, setAddMilestoneId] = useState(milestones[0]?.id ?? '');
|
||||||
|
const [addContent, setAddContent] = useState('');
|
||||||
|
const [addAuthor, setAddAuthor] = useState('');
|
||||||
|
const [editForms, setEditForms] = useState<Record<string, { content: string; authorName: string }>>({});
|
||||||
|
|
||||||
|
const sorted = [...details].sort(
|
||||||
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const milestoneTitle = (id: string | null) =>
|
||||||
|
milestones.find((m) => m.id === id)?.title ?? '—';
|
||||||
|
|
||||||
|
const startEdit = (detail: TaskDetail) => {
|
||||||
|
setExpandedId(detail.id);
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detail.id]: {
|
||||||
|
content: detail.content,
|
||||||
|
authorName: detail.authorName ?? detail.author?.name ?? '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async (detailId: string) => {
|
||||||
|
const form = editForms[detailId];
|
||||||
|
if (!form?.content.trim()) return;
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/details/item/${detailId}`, {
|
||||||
|
content: form.content.trim(),
|
||||||
|
authorName: form.authorName.trim() || null,
|
||||||
|
});
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
setExpandedId(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (detailId: string) => {
|
||||||
|
if (!window.confirm('피드백을 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/details/item/${detailId}`);
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFeedback = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!addMilestoneId || !addContent.trim() || !addAuthor.trim()) return;
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/details/${taskId}`, {
|
||||||
|
content: addContent.trim(),
|
||||||
|
authorName: addAuthor.trim(),
|
||||||
|
milestoneId: addMilestoneId,
|
||||||
|
});
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
setAdding(false);
|
||||||
|
setAddContent('');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '피드백 추가에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="task-manager-section">
|
||||||
|
<div className="task-manager-section__head">
|
||||||
|
<h4 className="task-manager-section__title">피드백</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn"
|
||||||
|
disabled={milestones.length === 0}
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
>
|
||||||
|
+ 피드백 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{milestones.length === 0 && (
|
||||||
|
<p className="task-form-empty">피드백을 추가하려면 먼저 업무 일정을 등록하세요.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adding && milestones.length > 0 && (
|
||||||
|
<form className="task-manager-subpanel task-manager-subpanel--new" onSubmit={addFeedback}>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label">연결 일정</label>
|
||||||
|
<select
|
||||||
|
value={addMilestoneId}
|
||||||
|
onChange={(e) => setAddMilestoneId(e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
>
|
||||||
|
{milestones.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label">피드백 내용 *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={addContent}
|
||||||
|
onChange={(e) => setAddContent(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label">작성자 *</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={addAuthor}
|
||||||
|
onChange={(e) => setAddAuthor(e.target.value)}
|
||||||
|
className="task-form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={() => setAdding(false)}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="task-form-btn task-form-btn--primary">
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sorted.length === 0 && !adding && milestones.length > 0 && (
|
||||||
|
<p className="task-form-empty">등록된 피드백이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sorted.map((detail) => {
|
||||||
|
const editing = expandedId === detail.id;
|
||||||
|
const form = editForms[detail.id] ?? {
|
||||||
|
content: detail.content,
|
||||||
|
authorName: detail.authorName ?? detail.author?.name ?? '',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<article key={detail.id} className={`task-manager-subrow${editing ? ' is-expanded' : ''}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-manager-subrow__head"
|
||||||
|
onClick={() => (editing ? setExpandedId(null) : startEdit(detail))}
|
||||||
|
>
|
||||||
|
<span className="task-manager-row__chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="task-manager-subrow__title truncate">{detail.content}</span>
|
||||||
|
<span className="task-manager-subrow__meta">
|
||||||
|
<span
|
||||||
|
className={`task-manager-badge task-manager-badge--status${
|
||||||
|
!detail.milestoneId ? ' is-legacy' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!detail.milestoneId ? '레거시·일정 미연결' : milestoneTitle(detail.milestoneId)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{editing && (
|
||||||
|
<div className="task-manager-subpanel">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label">피드백 내용</label>
|
||||||
|
<textarea
|
||||||
|
value={form.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detail.id]: { ...form, content: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
rows={3}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label">작성자</label>
|
||||||
|
<input
|
||||||
|
value={form.authorName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForms((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[detail.id]: { ...form, authorName: e.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="task-form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-btn task-form-btn--danger"
|
||||||
|
onClick={() => remove(detail.id)}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-btn task-form-btn--primary"
|
||||||
|
onClick={() => saveEdit(detail.id)}
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||||
import { TaskModal } from '../common/TaskModal';
|
import { TaskFormFields } from '../common/TaskFormFields';
|
||||||
import type { TaskFormData } from '../common/TaskModal';
|
import type { TaskFormData } from '../../lib/taskFormState';
|
||||||
import type { Task, TeamMember } from '../../types';
|
import { buildTaskFormState, STATUS_LABEL } from '../../lib/taskFormState';
|
||||||
|
import { TaskMilestoneSection } from './TaskMilestoneSection';
|
||||||
|
import { TaskFeedbackSection } from './TaskFeedbackSection';
|
||||||
|
import type { FileRecord, Milestone, Task, TaskDetail, TeamMember } from '../../types';
|
||||||
|
|
||||||
|
type TaskWithRelations = Task & {
|
||||||
|
milestones?: Milestone[];
|
||||||
|
files?: FileRecord[];
|
||||||
|
details?: TaskDetail[];
|
||||||
|
};
|
||||||
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
import { isProjectTask, isRoutineTask } from '../../lib/taskType';
|
||||||
import { taskFormToApiPayload } from '../../lib/taskFormPayload';
|
import { projectFormToApiPayload, routineFormToApiPayload } from '../../lib/taskFormPayload';
|
||||||
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
|
import { formatSectionDisplay } from '../../lib/sections';
|
||||||
|
import {
|
||||||
|
BOARD_SLOTS,
|
||||||
|
BOARD_SLOT_ORDER,
|
||||||
|
getBoardSlot,
|
||||||
|
resolveTaskBoardSlot,
|
||||||
|
slotSectionLabel,
|
||||||
|
type BoardSlotId,
|
||||||
|
} from '../../lib/boardLayout';
|
||||||
|
import { ROUTINE_CATEGORIES, getRoutineCategory } from '../../lib/routineCategories';
|
||||||
|
import '../../styles/task-manager.css';
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
type MainTab = 'project' | 'routine';
|
||||||
IN_PROGRESS: '진행', REVIEW: '보류', TODO: '대기', DONE: '완료', CANCELLED: '취소',
|
|
||||||
};
|
|
||||||
const STATUS_STYLE: Record<string, string> = {
|
|
||||||
IN_PROGRESS: 'bg-blue-100 text-blue-700',
|
|
||||||
REVIEW: 'bg-orange-100 text-orange-700',
|
|
||||||
TODO: 'bg-gray-100 text-gray-600',
|
|
||||||
DONE: 'bg-emerald-100 text-emerald-700',
|
|
||||||
CANCELLED: 'bg-gray-100 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
import { SECTIONS, formatSectionDisplay } from '../../lib/sections';
|
|
||||||
|
|
||||||
interface TaskManagerProps {
|
interface TaskManagerProps {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
@@ -30,206 +39,449 @@ interface TaskManagerProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
|
function fmtPeriod(task: Task): string {
|
||||||
|
if (!task.startDate && !task.dueDate) return '';
|
||||||
|
return `${task.startDate?.slice(0, 10) ?? '?'} ~ ${task.dueDate?.slice(0, 10) ?? '?'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotClassForTask(task: Task): string {
|
||||||
|
if (isRoutineTask(task.taskType)) return 'task-manager-row--routine';
|
||||||
|
const slotId = resolveTaskBoardSlot(task);
|
||||||
|
return slotId ? `task-manager-row--${slotId}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesSearch(task: Task, query: string): boolean {
|
||||||
|
if (!query.trim()) return true;
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const hay = [
|
||||||
|
task.title,
|
||||||
|
task.description,
|
||||||
|
task.detailDescription,
|
||||||
|
task.category,
|
||||||
|
task.section,
|
||||||
|
task.issueNote,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return hay.includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskManagerRow({
|
||||||
|
task,
|
||||||
|
variant,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
sectionOptions,
|
||||||
|
teamMembers,
|
||||||
|
quarter,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
task: Task;
|
||||||
|
variant: MainTab;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
sectionOptions: { value: string; label: string }[];
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
quarter: string;
|
||||||
|
onSaved: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [filterSection, setFilterSection] = useState<string>('전체');
|
const rowRef = useRef<HTMLElement>(null);
|
||||||
const [filterType, setFilterType] = useState<string>('전체');
|
const [form, setForm] = useState<TaskFormData>(() =>
|
||||||
const [modalMode, setModalMode] = useState<'add' | 'edit' | null>(null);
|
buildTaskFormState({ task, variant, defaultQuarter: quarter }),
|
||||||
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const { data: fullTask } = useQuery({
|
||||||
|
queryKey: ['task', task.id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<TaskWithRelations>(`/tasks/${task.id}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: expanded,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
setForm(buildTaskFormState({ task, variant, defaultQuarter: quarter }));
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
rowRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [expanded, task, variant, quarter]);
|
||||||
|
|
||||||
|
const patch = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) => apiClient.patch(`/tasks/${task.id}`, data),
|
||||||
|
onSuccess: () => invalidateTaskCaches(queryClient, task.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: () => apiClient.delete(`/tasks/${task.id}`),
|
||||||
|
onSuccess: () => invalidateTaskCaches(queryClient, task.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload =
|
||||||
|
variant === 'project'
|
||||||
|
? projectFormToApiPayload({ ...form, taskType: '실행과제' })
|
||||||
|
: routineFormToApiPayload({ ...form, taskType: '기반업무' });
|
||||||
|
await patch.mutateAsync(payload);
|
||||||
|
onSaved();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '저장에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await remove.mutateAsync();
|
||||||
|
onDeleted();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabel = STATUS_LABEL[task.status] ?? task.status;
|
||||||
|
const sectionLabel =
|
||||||
|
variant === 'project'
|
||||||
|
? formatSectionDisplay(task.section)
|
||||||
|
: getRoutineCategory(task) ?? task.category ?? '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
ref={rowRef}
|
||||||
|
className={`task-manager-row ${slotClassForTask(task)}${expanded ? ' is-expanded' : ''}`}
|
||||||
|
>
|
||||||
|
<button type="button" className="task-manager-row__summary" onClick={onToggle}>
|
||||||
|
<span className="task-manager-row__chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="task-manager-row__title">{task.title}</span>
|
||||||
|
<span className="task-manager-row__meta">
|
||||||
|
<span
|
||||||
|
className={`task-manager-badge ${variant === 'project' ? 'task-manager-badge--section' : 'task-manager-badge--category'}`}
|
||||||
|
>
|
||||||
|
{sectionLabel}
|
||||||
|
</span>
|
||||||
|
{variant === 'project' && (
|
||||||
|
<span className="task-manager-badge task-manager-badge--progress">{task.progress}%</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`task-manager-badge task-manager-badge--status${
|
||||||
|
task.status === 'DONE' ? ' is-done' : task.status === 'IN_PROGRESS' ? ' is-active' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
{variant === 'project' && fmtPeriod(task) && (
|
||||||
|
<span className="task-manager-badge task-manager-badge--status">{fmtPeriod(task)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<form className="task-manager-panel" onSubmit={handleSave}>
|
||||||
|
<TaskFormFields
|
||||||
|
variant={variant}
|
||||||
|
form={form}
|
||||||
|
onChange={setForm}
|
||||||
|
sectionOptions={sectionOptions}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
idPrefix={`mgr-${task.id}`}
|
||||||
|
/>
|
||||||
|
{fullTask && (
|
||||||
|
<>
|
||||||
|
<TaskMilestoneSection task={fullTask} variant={variant} teamMembers={teamMembers} />
|
||||||
|
<TaskFeedbackSection
|
||||||
|
taskId={task.id}
|
||||||
|
milestones={fullTask.milestones ?? []}
|
||||||
|
details={fullTask.details ?? []}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!fullTask && <p className="task-form-empty">업무 일정·피드백 불러오는 중…</p>}
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-btn task-form-btn--danger"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
|
||||||
|
{saving ? '저장 중…' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskManagerNewRow({
|
||||||
|
variant,
|
||||||
|
sectionOptions,
|
||||||
|
teamMembers,
|
||||||
|
quarter,
|
||||||
|
defaultSection,
|
||||||
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
variant: MainTab;
|
||||||
|
sectionOptions: { value: string; label: string }[];
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
quarter: string;
|
||||||
|
defaultSection: string;
|
||||||
|
onCreated: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = useState<TaskFormData>(() =>
|
||||||
|
buildTaskFormState({
|
||||||
|
variant,
|
||||||
|
defaultQuarter: quarter,
|
||||||
|
defaultSection,
|
||||||
|
defaultCategory: ROUTINE_CATEGORIES[0],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
|
mutationFn: (data: Record<string, unknown>) => apiClient.post('/tasks', data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
onSuccess: () => invalidateTaskCaches(queryClient),
|
||||||
});
|
|
||||||
const patch = useMutation({
|
|
||||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
|
||||||
apiClient.patch(`/tasks/${id}`, data),
|
|
||||||
onSuccess: (_data, { id }) => invalidateTaskCaches(queryClient, id),
|
|
||||||
});
|
|
||||||
const remove = useMutation({
|
|
||||||
mutationFn: (id: string) => apiClient.delete(`/tasks/${id}`),
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchType = (taskType: string | null | undefined, filter: string) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
if (filter === '전체') return true;
|
e.preventDefault();
|
||||||
if (filter === '실행과제') return isProjectTask(taskType);
|
setSaving(true);
|
||||||
if (filter === '기반업무') return isRoutineTask(taskType);
|
|
||||||
return taskType === filter;
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = tasks.filter((t) => {
|
|
||||||
if (filterSection !== '전체' && t.section !== filterSection) return false;
|
|
||||||
if (!matchType(t.taskType, filterType)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAdd = async (data: TaskFormData) => {
|
|
||||||
try {
|
try {
|
||||||
await create.mutateAsync({
|
const payload = {
|
||||||
...taskFormToApiPayload(data),
|
...(variant === 'project'
|
||||||
|
? projectFormToApiPayload({ ...form, taskType: '실행과제' })
|
||||||
|
: routineFormToApiPayload({ ...form, taskType: '기반업무' })),
|
||||||
priority: 'MEDIUM',
|
priority: 'MEDIUM',
|
||||||
});
|
};
|
||||||
setModalMode(null);
|
await create.mutateAsync(payload);
|
||||||
|
onCreated();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(getApiErrorMessage(err, '업무 추가에 실패했습니다.'));
|
alert(getApiErrorMessage(err, '추가에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (data: TaskFormData) => {
|
return (
|
||||||
if (!editingTask) return;
|
<article className={`task-manager-row is-expanded ${variant === 'routine' ? 'task-manager-row--routine' : 'task-manager-row--hrm'}`}>
|
||||||
patch.mutate({
|
<form className="task-manager-panel" style={{ paddingTop: 16 }} onSubmit={handleSubmit}>
|
||||||
id: editingTask.id,
|
<TaskFormFields
|
||||||
data: taskFormToApiPayload(data),
|
variant={variant}
|
||||||
});
|
form={form}
|
||||||
setModalMode(null);
|
onChange={setForm}
|
||||||
setEditingTask(null);
|
sectionOptions={sectionOptions}
|
||||||
};
|
teamMembers={teamMembers}
|
||||||
|
idPrefix="mgr-new"
|
||||||
|
/>
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onCancel}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={`task-form-btn task-form-btn--primary${variant === 'routine' ? ' is-routine' : ''}`}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '추가 중…' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleDelete = (task: Task) => {
|
export function TaskManager({ tasks, sectionOptions, quarter, teamMembers = [], onClose }: TaskManagerProps) {
|
||||||
if (window.confirm(`"${task.title}" 업무를 삭제하시겠습니까?`)) {
|
const [mainTab, setMainTab] = useState<MainTab>('project');
|
||||||
remove.mutate(task.id);
|
const [filterKey, setFilterKey] = useState<string>('전체');
|
||||||
}
|
const [search, setSearch] = useState('');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
const projectTasks = useMemo(
|
||||||
|
() => tasks.filter((t) => isProjectTask(t.taskType) && matchesSearch(t, search)),
|
||||||
|
[tasks, search],
|
||||||
|
);
|
||||||
|
const routineTasks = useMemo(
|
||||||
|
() => tasks.filter((t) => isRoutineTask(t.taskType) && matchesSearch(t, search)),
|
||||||
|
[tasks, search],
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectFilters = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: '전체', label: '전체' },
|
||||||
|
...BOARD_SLOTS.map((slot) => ({
|
||||||
|
key: slot.id,
|
||||||
|
label: slot.defaultTitle,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const routineFilters = useMemo(
|
||||||
|
() => [{ key: '전체', label: '전체' }, ...ROUTINE_CATEGORIES.map((cat) => ({ key: cat, label: cat }))],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredProjects = useMemo(() => {
|
||||||
|
if (filterKey === '전체') return projectTasks;
|
||||||
|
if (!BOARD_SLOT_ORDER.includes(filterKey as BoardSlotId)) return projectTasks;
|
||||||
|
const slot = getBoardSlot(filterKey as BoardSlotId);
|
||||||
|
return projectTasks.filter((t) => resolveTaskBoardSlot(t) === slot.id);
|
||||||
|
}, [projectTasks, filterKey]);
|
||||||
|
|
||||||
|
const filteredRoutines = useMemo(() => {
|
||||||
|
if (filterKey === '전체') return routineTasks;
|
||||||
|
return routineTasks.filter((t) => getRoutineCategory(t) === filterKey);
|
||||||
|
}, [routineTasks, filterKey]);
|
||||||
|
|
||||||
|
const visibleTasks = mainTab === 'project' ? filteredProjects : filteredRoutines;
|
||||||
|
const filters = mainTab === 'project' ? projectFilters : routineFilters;
|
||||||
|
|
||||||
|
const defaultSection =
|
||||||
|
filterKey !== '전체' && mainTab === 'project'
|
||||||
|
? slotSectionLabel(getBoardSlot(filterKey as BoardSlotId))
|
||||||
|
: sectionOptions[0]?.value ?? '인사관리';
|
||||||
|
|
||||||
|
const switchTab = (tab: MainTab) => {
|
||||||
|
setMainTab(tab);
|
||||||
|
setFilterKey('전체');
|
||||||
|
setExpandedId(null);
|
||||||
|
setAdding(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-[9000] flex items-center justify-center bg-black/60" onClick={onClose}>
|
<div className="task-manager-overlay" onClick={onClose}>
|
||||||
<div
|
<div className="task-manager-shell" onClick={(e) => e.stopPropagation()}>
|
||||||
className="bg-white rounded-2xl shadow-2xl flex flex-col"
|
<header className="task-manager-header">
|
||||||
style={{ width: '90vw', maxWidth: 1100, height: '85vh' }}
|
<div>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<h2 className="task-manager-header__title">업무관리</h2>
|
||||||
>
|
<p className="task-manager-header__quarter">{quarter}</p>
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 shrink-0">
|
|
||||||
<h2 className="text-xl font-black text-gray-800">📋 업무관리</h2>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 부문 필터 */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{['전체', ...SECTIONS].map((s) => (
|
|
||||||
<button key={s} onClick={() => setFilterSection(s)}
|
|
||||||
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
|
|
||||||
filterSection === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}>{s}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-px h-5 bg-gray-200" />
|
<button type="button" className="task-manager-close" onClick={onClose} aria-label="닫기">
|
||||||
{/* 유형 필터 */}
|
✕
|
||||||
<div className="flex gap-1">
|
</button>
|
||||||
{['전체', '실행과제', '기반업무'].map((t) => (
|
</header>
|
||||||
<button key={t} onClick={() => setFilterType(t)}
|
|
||||||
className={`text-xs font-semibold px-3 py-1.5 rounded-lg transition-colors ${
|
<div className="task-manager-toolbar">
|
||||||
filterType === t ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
<div className="task-manager-tabs">
|
||||||
}`}>{t}</button>
|
<button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
className={`task-manager-tab${mainTab === 'project' ? ' is-active' : ''}`}
|
||||||
<div className="w-px h-5 bg-gray-200" />
|
onClick={() => switchTab('project')}
|
||||||
<button
|
>
|
||||||
onClick={() => { setEditingTask(null); setModalMode('add'); }}
|
프로젝트
|
||||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-blue-600 text-white text-sm font-bold hover:bg-blue-700 transition-colors"
|
</button>
|
||||||
>
|
<button
|
||||||
✚ 업무 추가
|
type="button"
|
||||||
|
className={`task-manager-tab is-routine${mainTab === 'routine' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => switchTab('routine')}
|
||||||
|
>
|
||||||
|
상시업무
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-2xl leading-none ml-1">✕</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
<div className="task-manager-filters">
|
||||||
<div className="flex-1 overflow-y-auto">
|
{filters.map((f) => (
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="sticky top-0 bg-gray-50 border-b border-gray-200 z-10">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500 w-24">부문</th>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500 w-20">유형</th>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500">업무명</th>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48">내용</th>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500 w-40">기간</th>
|
|
||||||
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16">진행률</th>
|
|
||||||
<th className="text-center px-4 py-3 font-bold text-gray-500 w-16">상태</th>
|
|
||||||
<th className="text-left px-4 py-3 font-bold text-gray-500 w-48">이슈</th>
|
|
||||||
<th className="w-20" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-100">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<tr><td colSpan={9} className="text-center py-16 text-gray-300 text-lg">업무 없음</td></tr>
|
|
||||||
) : filtered.map((task) => (
|
|
||||||
<tr key={task.id} className="hover:bg-blue-50/40 transition-colors group">
|
|
||||||
<td className="px-4 py-3 text-gray-600 font-medium whitespace-nowrap">{formatSectionDisplay(task.section)}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
|
|
||||||
isRoutineTask(task.taskType) ? 'bg-amber-100 text-amber-700' : 'bg-blue-100 text-blue-700'
|
|
||||||
}`}>
|
|
||||||
{isRoutineTask(task.taskType) ? '기반업무' : '실행과제'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 font-semibold text-gray-800 max-w-[200px] truncate">{task.title}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-500 max-w-[200px] truncate">{task.description ?? '-'}</td>
|
|
||||||
<td className="px-4 py-3 text-gray-500 whitespace-nowrap text-xs">
|
|
||||||
{task.startDate || task.dueDate
|
|
||||||
? `${task.startDate?.slice(0,10) ?? '?'} ~ ${task.dueDate?.slice(0,10) ?? '?'}`
|
|
||||||
: '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<span className={`font-black text-base ${
|
|
||||||
task.progress >= 70 ? 'text-emerald-500' : task.progress >= 40 ? 'text-blue-500' : 'text-orange-400'
|
|
||||||
}`}>{task.progress}%</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${STATUS_STYLE[task.status]}`}>
|
|
||||||
{STATUS_LABEL[task.status]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-red-500 max-w-[200px] truncate text-xs">{task.issueNote ?? ''}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingTask(task); setModalMode('edit'); }}
|
key={f.key}
|
||||||
className="p-1.5 rounded-lg hover:bg-blue-100 text-blue-500 transition-colors"
|
type="button"
|
||||||
title="수정"
|
className={`task-manager-chip${filterKey === f.key ? ' is-active' : ''}`}
|
||||||
>✏</button>
|
onClick={() => {
|
||||||
<button
|
setFilterKey(f.key);
|
||||||
onClick={() => handleDelete(task)}
|
setExpandedId(null);
|
||||||
className="p-1.5 rounded-lg hover:bg-red-100 text-red-500 transition-colors"
|
}}
|
||||||
title="삭제"
|
>
|
||||||
>🗑</button>
|
{f.label}
|
||||||
</div>
|
</button>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 요약 */}
|
<div className="task-manager-toolbar__spacer" />
|
||||||
<div className="px-6 py-3 border-t border-gray-100 shrink-0 flex items-center gap-4 text-xs text-gray-400">
|
|
||||||
<span>전체 <strong className="text-gray-700">{filtered.length}</strong>건</span>
|
<input
|
||||||
<span>실행과제 <strong className="text-blue-600">{filtered.filter(t => isProjectTask(t.taskType)).length}</strong>건</span>
|
type="search"
|
||||||
<span>기반업무 <strong className="text-amber-600">{filtered.filter(t => isRoutineTask(t.taskType)).length}</strong>건</span>
|
className="task-manager-search"
|
||||||
</div>
|
placeholder="제목·개요 검색"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-manager-add"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(true);
|
||||||
|
setExpandedId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ {mainTab === 'project' ? '프로젝트' : '상시업무'} 추가
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalMode === 'add' && (
|
<div className="task-manager-list">
|
||||||
<TaskModal
|
{adding && (
|
||||||
mode="add"
|
<TaskManagerNewRow
|
||||||
defaultSection={filterSection !== '전체' ? filterSection : '인사관리'}
|
variant={mainTab}
|
||||||
defaultQuarter={quarter}
|
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
onSave={handleAdd}
|
quarter={quarter}
|
||||||
onClose={() => setModalMode(null)}
|
defaultSection={defaultSection}
|
||||||
|
onCreated={() => setAdding(false)}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modalMode === 'edit' && editingTask && (
|
|
||||||
<TaskModal
|
{visibleTasks.length === 0 && !adding ? (
|
||||||
mode="edit"
|
<p className="task-manager-empty">표시할 업무가 없습니다.</p>
|
||||||
task={editingTask}
|
) : (
|
||||||
|
visibleTasks.map((task) => (
|
||||||
|
<TaskManagerRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
variant={mainTab}
|
||||||
|
expanded={expandedId === task.id}
|
||||||
|
onToggle={() => setExpandedId((id) => (id === task.id ? null : task.id))}
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
onSave={handleEdit}
|
quarter={quarter}
|
||||||
onClose={() => { setModalMode(null); setEditingTask(null); }}
|
onSaved={() => setExpandedId(null)}
|
||||||
|
onDeleted={() => setExpandedId(null)}
|
||||||
/>
|
/>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="task-manager-footer">
|
||||||
|
<span>
|
||||||
|
프로젝트 <strong>{projectTasks.length}</strong>건
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
상시업무 <strong>{routineTasks.length}</strong>건
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
현재 목록 <strong>{visibleTasks.length}</strong>건
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
291
frontend/src/components/dashboard/TaskMilestoneSection.tsx
Normal file
291
frontend/src/components/dashboard/TaskMilestoneSection.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient, getApiErrorMessage } from '../../lib/apiClient';
|
||||||
|
import { StageFormFields } from '../common/StageFormFields';
|
||||||
|
import { buildStageFormState, stageFormToApiPayload } from '../../lib/stageFormState';
|
||||||
|
import type { StageFormData } from '../detail/stageFormTypes';
|
||||||
|
import { invalidateTaskCaches } from '../../lib/taskQueryCache';
|
||||||
|
import { fmtMilestonePeriodSummary } from '../../lib/milestonePeriods';
|
||||||
|
import {
|
||||||
|
StageModal,
|
||||||
|
type StageFileSavePayload,
|
||||||
|
} from '../detail/StageModal';
|
||||||
|
import { sortFilesByOrder } from '../../lib/fileDisplay';
|
||||||
|
import type { FileRecord, Milestone, TaskDetail, TeamMember } from '../../types';
|
||||||
|
|
||||||
|
type TaskWithRelations = {
|
||||||
|
id: string;
|
||||||
|
milestones?: Milestone[];
|
||||||
|
files?: FileRecord[];
|
||||||
|
details?: TaskDetail[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function MilestoneRow({
|
||||||
|
taskId,
|
||||||
|
milestone,
|
||||||
|
variant,
|
||||||
|
teamMembers,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
onOpenFiles,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
milestone: Milestone;
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
onOpenFiles: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = useState<StageFormData>(() => buildStageFormState(milestone, variant));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expanded) {
|
||||||
|
setForm(buildStageFormState(milestone, variant));
|
||||||
|
}
|
||||||
|
}, [expanded, milestone, variant]);
|
||||||
|
|
||||||
|
const save = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/milestones/item/${milestone.id}`, stageFormToApiPayload(form, variant));
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '업무 일정 저장에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!window.confirm(`"${milestone.title}" 일정을 삭제하시겠습니까?`)) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/milestones/item/${milestone.id}`);
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '삭제에 실패했습니다.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const periodSummary = fmtMilestonePeriodSummary(milestone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`task-manager-subrow${expanded ? ' is-expanded' : ''}`}>
|
||||||
|
<button type="button" className="task-manager-subrow__head" onClick={onToggle}>
|
||||||
|
<span className="task-manager-row__chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
<span className="task-manager-subrow__title">{milestone.title}</span>
|
||||||
|
<span className="task-manager-subrow__meta">
|
||||||
|
{periodSummary && <span className="task-manager-badge task-manager-badge--status">{periodSummary}</span>}
|
||||||
|
<span className="task-manager-badge task-manager-badge--progress">{milestone.progress}%</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<form className="task-manager-subpanel" onSubmit={save}>
|
||||||
|
<StageFormFields
|
||||||
|
variant={variant}
|
||||||
|
form={form}
|
||||||
|
onChange={setForm}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
idPrefix={`ms-${milestone.id}`}
|
||||||
|
/>
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onOpenFiles}>
|
||||||
|
첨부 파일 편집
|
||||||
|
</button>
|
||||||
|
<button type="button" className="task-form-btn task-form-btn--danger" onClick={remove}>
|
||||||
|
일정 삭제
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
|
||||||
|
{saving ? '저장 중…' : '일정 저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewMilestoneForm({
|
||||||
|
taskId,
|
||||||
|
variant,
|
||||||
|
teamMembers,
|
||||||
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
taskId: string;
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
onCreated: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form, setForm] = useState<StageFormData>(() => buildStageFormState(undefined, variant));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form.title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/milestones/${taskId}`, stageFormToApiPayload(form, variant));
|
||||||
|
invalidateTaskCaches(queryClient, taskId);
|
||||||
|
onCreated();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '업무 일정 추가에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="task-manager-subpanel task-manager-subpanel--new" onSubmit={submit}>
|
||||||
|
<StageFormFields
|
||||||
|
variant={variant}
|
||||||
|
form={form}
|
||||||
|
onChange={setForm}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
idPrefix="ms-new"
|
||||||
|
/>
|
||||||
|
<div className="task-manager-panel__actions">
|
||||||
|
<button type="button" className="task-form-btn task-form-btn--ghost" onClick={onCancel}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
|
||||||
|
{saving ? '추가 중…' : '일정 추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskMilestoneSection({
|
||||||
|
task,
|
||||||
|
variant,
|
||||||
|
teamMembers,
|
||||||
|
}: {
|
||||||
|
task: TaskWithRelations;
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
teamMembers: TeamMember[];
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const milestones = task.milestones ?? [];
|
||||||
|
const files = task.files ?? [];
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [fileModalMs, setFileModalMs] = useState<Milestone | null>(null);
|
||||||
|
const [fileSaving, setFileSaving] = useState(false);
|
||||||
|
|
||||||
|
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
|
||||||
|
for (const item of filePayload) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', item.file);
|
||||||
|
form.append('milestoneId', milestoneId);
|
||||||
|
form.append('sortOrder', String(item.sortOrder));
|
||||||
|
if (item.displayName.trim()) {
|
||||||
|
form.append('displayName', item.displayName.trim());
|
||||||
|
}
|
||||||
|
await apiClient.post(`/files/upload/${task.id}`, form);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSave = async (data: StageFormData, filePayload: StageFileSavePayload) => {
|
||||||
|
if (!fileModalMs) return;
|
||||||
|
setFileSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/milestones/item/${fileModalMs.id}`, stageFormToApiPayload(data, variant));
|
||||||
|
for (const id of filePayload.deletedFileIds) {
|
||||||
|
await apiClient.delete(`/files/${id}`);
|
||||||
|
}
|
||||||
|
for (const rep of filePayload.replacements) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', rep.file);
|
||||||
|
await apiClient.post(`/files/${rep.id}/replace`, form);
|
||||||
|
}
|
||||||
|
for (const edit of filePayload.existingEdits) {
|
||||||
|
const original = files.find((f) => f.id === edit.id);
|
||||||
|
if (!original) continue;
|
||||||
|
const prevName = (original.displayName ?? '').trim();
|
||||||
|
const nextName = edit.displayName.trim();
|
||||||
|
const prevOrder = original.sortOrder ?? 0;
|
||||||
|
if (nextName !== prevName || edit.sortOrder !== prevOrder) {
|
||||||
|
await apiClient.patch(`/files/${edit.id}`, {
|
||||||
|
displayName: nextName || null,
|
||||||
|
sortOrder: edit.sortOrder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filePayload.uploads.length > 0) {
|
||||||
|
await uploadFiles(fileModalMs.id, filePayload.uploads);
|
||||||
|
}
|
||||||
|
invalidateTaskCaches(queryClient, task.id);
|
||||||
|
setFileModalMs(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '첨부 파일 저장에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setFileSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="task-manager-section">
|
||||||
|
<div className="task-manager-section__head">
|
||||||
|
<h4 className="task-manager-section__title">업무 일정</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="task-form-link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(true);
|
||||||
|
setExpandedId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ 일정 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<NewMilestoneForm
|
||||||
|
taskId={task.id}
|
||||||
|
variant={variant}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
onCreated={() => setAdding(false)}
|
||||||
|
onCancel={() => setAdding(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{milestones.length === 0 && !adding ? (
|
||||||
|
<p className="task-form-empty">등록된 업무 일정이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
milestones.map((ms) => (
|
||||||
|
<MilestoneRow
|
||||||
|
key={ms.id}
|
||||||
|
taskId={task.id}
|
||||||
|
milestone={ms}
|
||||||
|
variant={variant}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
expanded={expandedId === ms.id}
|
||||||
|
onToggle={() => setExpandedId((id) => (id === ms.id ? null : ms.id))}
|
||||||
|
onOpenFiles={() => setFileModalMs(ms)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileModalMs && (
|
||||||
|
<StageModal
|
||||||
|
mode="edit"
|
||||||
|
variant={variant}
|
||||||
|
milestone={fileModalMs}
|
||||||
|
existingFiles={sortFilesByOrder(files.filter((f) => f.milestoneId === fileModalMs.id))}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
saving={fileSaving}
|
||||||
|
onClose={() => setFileModalMs(null)}
|
||||||
|
onSave={handleFileSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,110 +1,152 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
buildMilestoneTimeline,
|
buildMilestoneTimeline,
|
||||||
|
computeFullTimelineRange,
|
||||||
|
milestoneHasTimelineDates,
|
||||||
|
panPortfolioViewport,
|
||||||
|
resolveTimelineInitialViewport,
|
||||||
|
zoomPortfolioViewport,
|
||||||
|
type PortfolioTaskGroup,
|
||||||
type TimelineMilestoneInput,
|
type TimelineMilestoneInput,
|
||||||
type TimelineRangeFallback,
|
type TimelineRangeFallback,
|
||||||
} from '../../lib/milestoneTimeline';
|
} from '../../lib/milestoneTimeline';
|
||||||
|
|
||||||
|
/** focus: 선택 업무 3개월 | project: 프로젝트/해당 업무 전체 | portfolio: 분기 전체 */
|
||||||
|
export type TimelineViewMode = 'focus' | 'project' | 'portfolio';
|
||||||
|
|
||||||
interface MilestoneTimelineProps {
|
interface MilestoneTimelineProps {
|
||||||
milestones: TimelineMilestoneInput[];
|
milestones: TimelineMilestoneInput[];
|
||||||
|
portfolioTasks?: PortfolioTaskGroup[];
|
||||||
fallback?: TimelineRangeFallback;
|
fallback?: TimelineRangeFallback;
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
onSelect?: (id: string) => void;
|
onSelect?: (id: string) => void;
|
||||||
|
ownerTaskId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
preserveRowOrder?: boolean;
|
|
||||||
/** 상시업무 — 시작·종료일 없는 단계는 게이지 미표시 */
|
|
||||||
hideUndatedBars?: boolean;
|
hideUndatedBars?: boolean;
|
||||||
|
variant?: 'project' | 'routine';
|
||||||
|
viewMode?: TimelineViewMode;
|
||||||
|
onViewModeChange?: (mode: TimelineViewMode) => void;
|
||||||
|
focusTitle?: string;
|
||||||
|
projectTitle?: string;
|
||||||
|
portfolioTitle?: string;
|
||||||
|
rowLabelHeader?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VARIANT_DEFAULTS = {
|
||||||
|
project: {
|
||||||
|
focusTitle: '업무별 타임라인',
|
||||||
|
projectTitle: '프로젝트 전체 일정',
|
||||||
|
portfolioTitle: '분기 전체 프로젝트',
|
||||||
|
rowLabelHeader: '업무 일정',
|
||||||
|
focusTab: '선택 업무',
|
||||||
|
projectTab: '프로젝트',
|
||||||
|
portfolioTab: '분기 전체',
|
||||||
|
},
|
||||||
|
routine: {
|
||||||
|
focusTitle: '업무명별 타임라인',
|
||||||
|
projectTitle: '상시 전체 일정',
|
||||||
|
portfolioTitle: '분기 전체 상시업무',
|
||||||
|
rowLabelHeader: '업무명',
|
||||||
|
focusTab: '선택 업무',
|
||||||
|
projectTab: '분류',
|
||||||
|
portfolioTab: '분기 전체',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type PortfolioRow =
|
||||||
|
| { kind: 'group'; taskId: string; title: string }
|
||||||
|
| {
|
||||||
|
kind: 'milestone';
|
||||||
|
taskId: string;
|
||||||
|
milestoneId: string;
|
||||||
|
title: string;
|
||||||
|
progress: number;
|
||||||
|
segments: Array<{ id: string; leftPct: number; widthPct: number; progress: number }>;
|
||||||
|
isCurrentTask: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function MilestoneTimeline({
|
export function MilestoneTimeline({
|
||||||
milestones,
|
milestones,
|
||||||
|
portfolioTasks,
|
||||||
fallback = {},
|
fallback = {},
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
title = '업무별 타임라인',
|
ownerTaskId,
|
||||||
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
emptyMessage = '기간을 설정한 단계만 표시됩니다.',
|
emptyMessage = '기간을 설정한 단계만 표시됩니다.',
|
||||||
preserveRowOrder = false,
|
|
||||||
hideUndatedBars = true,
|
hideUndatedBars = true,
|
||||||
className = '',
|
className = '',
|
||||||
|
variant = 'project',
|
||||||
|
viewMode = 'focus',
|
||||||
|
onViewModeChange,
|
||||||
|
focusTitle,
|
||||||
|
projectTitle,
|
||||||
|
portfolioTitle,
|
||||||
|
rowLabelHeader,
|
||||||
}: MilestoneTimelineProps) {
|
}: MilestoneTimelineProps) {
|
||||||
const model = useMemo(
|
const defaults = VARIANT_DEFAULTS[variant];
|
||||||
() => buildMilestoneTimeline(milestones, fallback, { preserveOrder: preserveRowOrder, hideUndatedBars }),
|
const isPortfolio = viewMode === 'portfolio';
|
||||||
[milestones, fallback, preserveRowOrder, hideUndatedBars],
|
const isGantt = viewMode !== 'focus';
|
||||||
|
const displayTitle =
|
||||||
|
title ??
|
||||||
|
(viewMode === 'portfolio'
|
||||||
|
? (portfolioTitle ?? defaults.portfolioTitle)
|
||||||
|
: viewMode === 'project'
|
||||||
|
? (projectTitle ?? defaults.projectTitle)
|
||||||
|
: (focusTitle ?? defaults.focusTitle));
|
||||||
|
const labelHeader = rowLabelHeader ?? defaults.rowLabelHeader;
|
||||||
|
|
||||||
|
const portfolioFlatMilestones = useMemo(() => {
|
||||||
|
if (!portfolioTasks?.length) return [];
|
||||||
|
return portfolioTasks.flatMap((t) => t.milestones);
|
||||||
|
}, [portfolioTasks]);
|
||||||
|
|
||||||
|
const chartMilestones = isPortfolio ? portfolioFlatMilestones : milestones;
|
||||||
|
|
||||||
|
const dataRange = useMemo(
|
||||||
|
() => computeFullTimelineRange(chartMilestones, fallback),
|
||||||
|
[chartMilestones, fallback],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const [chartViewport, setChartViewport] = useState<{ start: Date; end: Date } | null>(null);
|
||||||
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
||||||
const measureRef = useRef<HTMLSpanElement>(null);
|
|
||||||
const [expandedBar, setExpandedBar] = useState<{
|
|
||||||
id: string;
|
|
||||||
widthPx: number;
|
|
||||||
leftPx: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const measureTitleWidth = (title: string) => {
|
|
||||||
const el = measureRef.current;
|
|
||||||
if (!el) return 0;
|
|
||||||
el.textContent = title;
|
|
||||||
return el.offsetWidth + 16;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBarEnter = (
|
|
||||||
row: { id: string; title: string; leftPct: number; widthPct: number },
|
|
||||||
event: React.MouseEvent<HTMLButtonElement>,
|
|
||||||
) => {
|
|
||||||
const chart = chartRef.current;
|
|
||||||
if (!chart) return;
|
|
||||||
|
|
||||||
const btn = event.currentTarget;
|
|
||||||
const chartWidth = chart.clientWidth;
|
|
||||||
const leftPx = (row.leftPct / 100) * chartWidth;
|
|
||||||
const origWidthPx = btn.offsetWidth;
|
|
||||||
const neededWidthPx = measureTitleWidth(row.title);
|
|
||||||
|
|
||||||
if (neededWidthPx <= origWidthPx + 1) return;
|
|
||||||
|
|
||||||
const widthPx = Math.min(chartWidth, neededWidthPx);
|
|
||||||
const extra = widthPx - origWidthPx;
|
|
||||||
let expandedLeftPx = leftPx - extra / 2;
|
|
||||||
|
|
||||||
if (expandedLeftPx < 0) expandedLeftPx = 0;
|
|
||||||
if (expandedLeftPx + widthPx > chartWidth) expandedLeftPx = chartWidth - widthPx;
|
|
||||||
|
|
||||||
setExpandedBar({
|
|
||||||
id: row.id,
|
|
||||||
widthPx,
|
|
||||||
leftPx: expandedLeftPx,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId) return;
|
if (!dataRange) {
|
||||||
const row = rowRefs.current.get(selectedId);
|
setChartViewport(null);
|
||||||
const chart = chartRef.current;
|
return;
|
||||||
if (!row || !chart) return;
|
}
|
||||||
|
setChartViewport(resolveTimelineInitialViewport(viewMode, chartMilestones, fallback, selectedId));
|
||||||
|
}, [
|
||||||
|
viewMode,
|
||||||
|
dataRange?.start.getTime(),
|
||||||
|
dataRange?.end.getTime(),
|
||||||
|
viewMode === 'focus' ? selectedId : undefined,
|
||||||
|
]);
|
||||||
|
|
||||||
const rowRect = row.getBoundingClientRect();
|
const model = useMemo(() => {
|
||||||
const chartRect = chart.getBoundingClientRect();
|
if (!chartViewport) return null;
|
||||||
|
return buildMilestoneTimeline(chartMilestones, fallback, {
|
||||||
|
hideUndatedBars,
|
||||||
|
focusQuarter: false,
|
||||||
|
viewport: chartViewport,
|
||||||
|
ganttBars: isGantt,
|
||||||
|
});
|
||||||
|
}, [chartMilestones, fallback, hideUndatedBars, isGantt, chartViewport]);
|
||||||
|
|
||||||
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const labelsScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const delta =
|
const rowRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
rowRect.top < chartRect.top
|
const dragRef = useRef<{ startX: number; viewport: { start: Date; end: Date } } | null>(null);
|
||||||
? rowRect.top - chartRect.top
|
const didPanRef = useRef(false);
|
||||||
: rowRect.bottom - chartRect.bottom;
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
|
|
||||||
chart.scrollBy({ top: delta, behavior: 'smooth' });
|
|
||||||
}, [selectedId, model?.rows.length]);
|
|
||||||
|
|
||||||
const rangeSubtitle =
|
|
||||||
subtitle ?? (model ? `${model.rangeStartLabel} ~ ${model.rangeEndLabel}` : undefined);
|
|
||||||
|
|
||||||
const rowGroups = useMemo(() => {
|
const rowGroups = useMemo(() => {
|
||||||
if (!model) return [];
|
if (!model) return [];
|
||||||
|
const titleById = new Map(chartMilestones.map((m) => [m.id, m.title]));
|
||||||
const groups: Array<{
|
const groups: Array<{
|
||||||
milestoneId: string;
|
milestoneId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -118,7 +160,7 @@ export function MilestoneTimeline({
|
|||||||
indexByMilestone.set(row.milestoneId, groups.length);
|
indexByMilestone.set(row.milestoneId, groups.length);
|
||||||
groups.push({
|
groups.push({
|
||||||
milestoneId: row.milestoneId,
|
milestoneId: row.milestoneId,
|
||||||
title: row.title,
|
title: titleById.get(row.milestoneId) ?? row.title,
|
||||||
progress: row.progress,
|
progress: row.progress,
|
||||||
segments: [row],
|
segments: [row],
|
||||||
});
|
});
|
||||||
@@ -127,23 +169,163 @@ export function MilestoneTimeline({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [model]);
|
}, [model, chartMilestones]);
|
||||||
|
|
||||||
return (
|
const rowGroupByMilestone = useMemo(() => {
|
||||||
<footer className={`milestone-timeline ${className}`.trim()}>
|
const map = new Map<string, (typeof rowGroups)[number]>();
|
||||||
<div className="milestone-timeline__head">
|
for (const g of rowGroups) map.set(g.milestoneId, g);
|
||||||
<span className="milestone-timeline__title">{title}</span>
|
return map;
|
||||||
{rangeSubtitle && (
|
}, [rowGroups]);
|
||||||
<span className="milestone-timeline__subtitle truncate">{rangeSubtitle}</span>
|
|
||||||
|
const portfolioRows = useMemo((): PortfolioRow[] => {
|
||||||
|
if (!isPortfolio || !portfolioTasks) return [];
|
||||||
|
const rows: PortfolioRow[] = [];
|
||||||
|
for (const task of portfolioTasks) {
|
||||||
|
const dated = task.milestones.filter((m) =>
|
||||||
|
hideUndatedBars ? milestoneHasTimelineDates(m) : true,
|
||||||
|
);
|
||||||
|
if (dated.length === 0) continue;
|
||||||
|
rows.push({ kind: 'group', taskId: task.id, title: task.title });
|
||||||
|
for (const m of dated) {
|
||||||
|
const group = rowGroupByMilestone.get(m.id);
|
||||||
|
rows.push({
|
||||||
|
kind: 'milestone',
|
||||||
|
taskId: task.id,
|
||||||
|
milestoneId: m.id,
|
||||||
|
title: m.title,
|
||||||
|
progress: group?.progress ?? m.progress ?? 0,
|
||||||
|
segments: group?.segments ?? [],
|
||||||
|
isCurrentTask: task.id === ownerTaskId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [isPortfolio, portfolioTasks, rowGroupByMilestone, hideUndatedBars, ownerTaskId]);
|
||||||
|
|
||||||
|
const projectRowGroups = useMemo(() => {
|
||||||
|
if (isPortfolio) return [];
|
||||||
|
return rowGroups;
|
||||||
|
}, [isPortfolio, rowGroups]);
|
||||||
|
|
||||||
|
const syncScroll = (source: 'labels' | 'chart') => {
|
||||||
|
const labels = labelsScrollRef.current;
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!labels || !chart) return;
|
||||||
|
if (source === 'labels') chart.scrollTop = labels.scrollTop;
|
||||||
|
else labels.scrollTop = chart.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChartPanStart = (e: React.MouseEvent) => {
|
||||||
|
if (!chartViewport || !dataRange || e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
didPanRef.current = false;
|
||||||
|
dragRef.current = { startX: e.clientX, viewport: chartViewport };
|
||||||
|
setIsPanning(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChartPanMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || !dataRange || !chartRef.current) return;
|
||||||
|
if (Math.abs(e.clientX - drag.startX) > 4) didPanRef.current = true;
|
||||||
|
const chartWidth = chartRef.current.clientWidth;
|
||||||
|
if (chartWidth <= 0) return;
|
||||||
|
const windowMs = drag.viewport.end.getTime() - drag.viewport.start.getTime();
|
||||||
|
const deltaMs = -((e.clientX - drag.startX) / chartWidth) * windowMs;
|
||||||
|
setChartViewport(panPortfolioViewport(drag.viewport, dataRange, deltaMs));
|
||||||
|
},
|
||||||
|
[dataRange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChartPanEnd = useCallback(() => {
|
||||||
|
dragRef.current = null;
|
||||||
|
setIsPanning(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPanning) return;
|
||||||
|
window.addEventListener('mousemove', handleChartPanMove);
|
||||||
|
window.addEventListener('mouseup', handleChartPanEnd);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleChartPanMove);
|
||||||
|
window.removeEventListener('mouseup', handleChartPanEnd);
|
||||||
|
};
|
||||||
|
}, [isPanning, handleChartPanMove, handleChartPanEnd]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartViewport || !dataRange) return;
|
||||||
|
const el = chartRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const anchorPct = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5;
|
||||||
|
const factor = e.deltaY > 0 ? 1.12 : 0.88;
|
||||||
|
setChartViewport(zoomPortfolioViewport(chartViewport, dataRange, factor, anchorPct));
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, [chartViewport, dataRange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId || !isGantt || isPortfolio) return;
|
||||||
|
const row = rowRefs.current.get(selectedId);
|
||||||
|
const chart = chartRef.current;
|
||||||
|
if (!row || !chart) return;
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const chartRect = chart.getBoundingClientRect();
|
||||||
|
if (rowRect.top >= chartRect.top && rowRect.bottom <= chartRect.bottom) return;
|
||||||
|
const delta =
|
||||||
|
rowRect.top < chartRect.top
|
||||||
|
? rowRect.top - chartRect.top
|
||||||
|
: rowRect.bottom - chartRect.bottom;
|
||||||
|
chart.scrollBy({ top: delta, behavior: 'smooth' });
|
||||||
|
}, [selectedId, model?.rows.length, viewMode, isGantt, isPortfolio]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPanning(false);
|
||||||
|
dragRef.current = null;
|
||||||
|
didPanRef.current = false;
|
||||||
|
}, [viewMode]);
|
||||||
|
|
||||||
|
const handleRowSelect = (milestoneId: string, taskId: string) => {
|
||||||
|
if (didPanRef.current) return;
|
||||||
|
if (isPortfolio && ownerTaskId && taskId !== ownerTaskId) return;
|
||||||
|
onSelect?.(milestoneId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBar = (
|
||||||
|
group: {
|
||||||
|
milestoneId: string;
|
||||||
|
title: string;
|
||||||
|
progress: number;
|
||||||
|
segments: Array<{ id: string; leftPct: number; widthPct: number; progress: number }>;
|
||||||
|
},
|
||||||
|
isSelected: boolean,
|
||||||
|
taskId: string,
|
||||||
|
showGanttBar: boolean,
|
||||||
|
) =>
|
||||||
|
group.segments.map((row) => (
|
||||||
|
<button
|
||||||
|
key={row.id}
|
||||||
|
type="button"
|
||||||
|
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${showGanttBar ? 'is-gantt' : ''}`}
|
||||||
|
style={{ left: `${row.leftPct}%`, width: `${row.widthPct}%` }}
|
||||||
|
aria-label={group.title}
|
||||||
|
title={group.title}
|
||||||
|
onClick={() => handleRowSelect(group.milestoneId, taskId)}
|
||||||
|
>
|
||||||
|
<span className="milestone-timeline__bar-track" />
|
||||||
|
<span className="milestone-timeline__bar-fill" style={{ width: `${row.progress}%` }} />
|
||||||
|
{showGanttBar && group.progress > 0 && (
|
||||||
|
<span className="milestone-timeline__bar-progress">{group.progress}%</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
|
));
|
||||||
|
|
||||||
{!model ? (
|
const renderTicks = () => (
|
||||||
<p className="milestone-timeline__empty">{emptyMessage}</p>
|
|
||||||
) : (
|
|
||||||
<div className="milestone-timeline__body">
|
|
||||||
<div className="milestone-timeline__ticks" aria-hidden="true">
|
<div className="milestone-timeline__ticks" aria-hidden="true">
|
||||||
{model.ticks.map((tick) => (
|
{model!.ticks.map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={`${tick.label}-${tick.leftPct}`}
|
key={`${tick.label}-${tick.leftPct}`}
|
||||||
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
|
className={`milestone-timeline__tick${tick.isToday ? ' is-today' : ''}`}
|
||||||
@@ -154,23 +336,192 @@ export function MilestoneTimeline({
|
|||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="milestone-timeline__chart" ref={chartRef}>
|
const renderGrid = (includeToday = true) => (
|
||||||
<span ref={measureRef} className="milestone-timeline__measure" aria-hidden="true" />
|
|
||||||
<div className="milestone-timeline__grid" aria-hidden="true">
|
<div className="milestone-timeline__grid" aria-hidden="true">
|
||||||
{model.ticks.map((tick) => (
|
{model!.ticks.map((tick) => (
|
||||||
<span
|
<span
|
||||||
key={`grid-${tick.label}-${tick.leftPct}`}
|
key={`grid-${tick.label}-${tick.leftPct}`}
|
||||||
className="milestone-timeline__grid-line"
|
className="milestone-timeline__grid-line"
|
||||||
style={{ left: `${tick.leftPct}%` }}
|
style={{ left: `${tick.leftPct}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{includeToday && model!.todayLeftPct != null && (
|
||||||
|
<span
|
||||||
|
className="milestone-timeline__today-line"
|
||||||
|
style={{ left: `${model!.todayLeftPct}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const rowCount = isPortfolio ? portfolioRows.length : projectRowGroups.length;
|
||||||
|
const chartPannableClass = `milestone-timeline__chart milestone-timeline__chart--pannable${isPanning ? ' is-panning' : ''}`;
|
||||||
|
|
||||||
|
const renderChartHead = () => (
|
||||||
|
<div className="milestone-timeline__chart-head">
|
||||||
|
{renderTicks()}
|
||||||
|
<span className="milestone-timeline__viewport-range">
|
||||||
|
{model!.rangeStartLabel}~{model!.rangeEndLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPanHint = () => (
|
||||||
|
<p className="milestone-timeline__pan-hint">
|
||||||
|
드래그: 기간 이동 · 휠: 확대/축소 · {model!.rangeStartLabel}~{model!.rangeEndLabel}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
className={`milestone-timeline ${isGantt ? 'milestone-timeline--gantt' : ''} ${isPortfolio ? 'milestone-timeline--portfolio' : ''} ${className}`.trim()}
|
||||||
|
style={rowCount > 0 ? ({ '--mt-row-count': String(rowCount) } as React.CSSProperties) : undefined}
|
||||||
|
>
|
||||||
|
<div className="milestone-timeline__head">
|
||||||
|
<span className="milestone-timeline__title">{displayTitle}</span>
|
||||||
|
{subtitle && <span className="milestone-timeline__subtitle truncate">{subtitle}</span>}
|
||||||
|
{onViewModeChange && (
|
||||||
|
<div className="milestone-timeline__view-toggle" role="tablist" aria-label="타임라인 보기">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'focus'}
|
||||||
|
className={viewMode === 'focus' ? 'is-active' : ''}
|
||||||
|
onClick={() => onViewModeChange('focus')}
|
||||||
|
>
|
||||||
|
{defaults.focusTab}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'project'}
|
||||||
|
className={viewMode === 'project' ? 'is-active' : ''}
|
||||||
|
onClick={() => onViewModeChange('project')}
|
||||||
|
>
|
||||||
|
{defaults.projectTab}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={viewMode === 'portfolio'}
|
||||||
|
className={viewMode === 'portfolio' ? 'is-active' : ''}
|
||||||
|
onClick={() => onViewModeChange('portfolio')}
|
||||||
|
>
|
||||||
|
{defaults.portfolioTab}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!model ? (
|
||||||
|
<p className="milestone-timeline__empty">{emptyMessage}</p>
|
||||||
|
) : isPortfolio ? (
|
||||||
|
<div className="milestone-timeline__portfolio-layout">
|
||||||
|
<div className="milestone-timeline__portfolio-head">
|
||||||
|
<div className="milestone-timeline__row-labels-head">{labelHeader}</div>
|
||||||
|
<div className="milestone-timeline__portfolio-chart-head">
|
||||||
|
<div className="milestone-timeline__body milestone-timeline__body--ticks-only">
|
||||||
|
{renderTicks()}
|
||||||
|
</div>
|
||||||
|
{model && (
|
||||||
|
<span className="milestone-timeline__viewport-range">
|
||||||
|
{model.rangeStartLabel}~{model.rangeEndLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="milestone-timeline__portfolio-scroll">
|
||||||
|
<div
|
||||||
|
className="milestone-timeline__row-labels-body"
|
||||||
|
ref={labelsScrollRef}
|
||||||
|
onScroll={() => syncScroll('labels')}
|
||||||
|
>
|
||||||
|
{portfolioRows.map((row) =>
|
||||||
|
row.kind === 'group' ? (
|
||||||
|
<div
|
||||||
|
key={`group-${row.taskId}`}
|
||||||
|
className="milestone-timeline__group-label"
|
||||||
|
title={row.title}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={row.milestoneId}
|
||||||
|
type="button"
|
||||||
|
className={`milestone-timeline__row-label milestone-timeline__row-label--child${row.milestoneId === selectedId && row.isCurrentTask ? ' is-selected' : ''}${row.isCurrentTask ? ' is-current-task' : ' is-other-task'}`}
|
||||||
|
onClick={() => handleRowSelect(row.milestoneId, row.taskId)}
|
||||||
|
title={row.title}
|
||||||
|
>
|
||||||
|
{row.title}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={chartPannableClass}
|
||||||
|
ref={chartRef}
|
||||||
|
onMouseDown={handleChartPanStart}
|
||||||
|
onScroll={() => syncScroll('chart')}
|
||||||
|
>
|
||||||
|
{renderGrid()}
|
||||||
<div className="milestone-timeline__rows">
|
<div className="milestone-timeline__rows">
|
||||||
{rowGroups.map((group) => {
|
{portfolioRows.map((row) =>
|
||||||
const isSelected = group.milestoneId === selectedId;
|
row.kind === 'group' ? (
|
||||||
return (
|
<div key={`g-${row.taskId}`} className="milestone-timeline__row milestone-timeline__row--group" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={row.milestoneId}
|
||||||
|
className={`milestone-timeline__row${row.isCurrentTask ? ' is-current-task' : ' is-other-task'}`}
|
||||||
|
>
|
||||||
|
{renderBar(
|
||||||
|
row,
|
||||||
|
row.milestoneId === selectedId && row.isCurrentTask,
|
||||||
|
row.taskId,
|
||||||
|
true,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderPanHint()}
|
||||||
|
</div>
|
||||||
|
) : isGantt ? (
|
||||||
|
<>
|
||||||
|
<div className="milestone-timeline__gantt-layout">
|
||||||
|
<div className="milestone-timeline__row-labels">
|
||||||
|
<div className="milestone-timeline__row-labels-head">{labelHeader}</div>
|
||||||
|
<div className="milestone-timeline__row-labels-body">
|
||||||
|
{projectRowGroups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.milestoneId}
|
||||||
|
type="button"
|
||||||
|
className={`milestone-timeline__row-label${group.milestoneId === selectedId ? ' is-selected' : ''}`}
|
||||||
|
onClick={() => handleRowSelect(group.milestoneId, ownerTaskId ?? '')}
|
||||||
|
title={group.title}
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="milestone-timeline__body">
|
||||||
|
{renderChartHead()}
|
||||||
|
<div
|
||||||
|
className={chartPannableClass}
|
||||||
|
ref={chartRef}
|
||||||
|
onMouseDown={handleChartPanStart}
|
||||||
|
>
|
||||||
|
{renderGrid()}
|
||||||
|
<div className="milestone-timeline__rows">
|
||||||
|
{projectRowGroups.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.milestoneId}
|
key={group.milestoneId}
|
||||||
className="milestone-timeline__row"
|
className="milestone-timeline__row"
|
||||||
@@ -179,58 +530,34 @@ export function MilestoneTimeline({
|
|||||||
else rowRefs.current.delete(group.milestoneId);
|
else rowRefs.current.delete(group.milestoneId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.segments.map((row) => {
|
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', true)}
|
||||||
const isExpanded = expandedBar?.id === row.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={row.id}
|
|
||||||
type="button"
|
|
||||||
className={`milestone-timeline__bar ${isSelected ? 'is-selected' : ''} ${isExpanded ? 'is-expanded' : ''}`}
|
|
||||||
style={
|
|
||||||
isExpanded
|
|
||||||
? {
|
|
||||||
left: `${expandedBar.leftPx}px`,
|
|
||||||
width: `${expandedBar.widthPx}px`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
left: `${row.leftPct}%`,
|
|
||||||
width: `${row.widthPct}%`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aria-label={group.title}
|
|
||||||
title={group.title}
|
|
||||||
onMouseEnter={(e) => handleBarEnter({ ...row, title: group.title }, e)}
|
|
||||||
onMouseLeave={() => setExpandedBar(null)}
|
|
||||||
onClick={() => onSelect?.(group.milestoneId)}
|
|
||||||
>
|
|
||||||
<span className="milestone-timeline__bar-track" />
|
|
||||||
<span
|
|
||||||
className="milestone-timeline__bar-fill"
|
|
||||||
style={{ width: `${row.progress}%` }}
|
|
||||||
/>
|
|
||||||
<span className="milestone-timeline__bar-label-wrap" aria-hidden="true">
|
|
||||||
<span
|
|
||||||
className="milestone-timeline__bar-label milestone-timeline__bar-label--fill"
|
|
||||||
style={{ clipPath: `inset(0 ${100 - row.progress}% 0 0)` }}
|
|
||||||
>
|
|
||||||
{group.title}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="milestone-timeline__bar-label milestone-timeline__bar-label--track"
|
|
||||||
style={{ clipPath: `inset(0 0 0 ${row.progress}%)` }}
|
|
||||||
>
|
|
||||||
{group.title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderPanHint()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="milestone-timeline__focus-layout">
|
||||||
|
{renderChartHead()}
|
||||||
|
<div
|
||||||
|
className={chartPannableClass}
|
||||||
|
ref={chartRef}
|
||||||
|
onMouseDown={handleChartPanStart}
|
||||||
|
>
|
||||||
|
{renderGrid()}
|
||||||
|
<div className="milestone-timeline__rows">
|
||||||
|
{projectRowGroups.map((group) => (
|
||||||
|
<div key={group.milestoneId} className="milestone-timeline__row">
|
||||||
|
{renderBar(group, group.milestoneId === selectedId, ownerTaskId ?? '', false)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderPanHint()}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
80
frontend/src/components/detail/OverviewEditModal.tsx
Normal file
80
frontend/src/components/detail/OverviewEditModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
export interface OverviewFormData {
|
||||||
|
description: string;
|
||||||
|
detailDescription: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewEditModalProps {
|
||||||
|
initial: OverviewFormData;
|
||||||
|
onSave: (data: OverviewFormData) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewEditModal({
|
||||||
|
initial,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
saving,
|
||||||
|
}: OverviewEditModalProps) {
|
||||||
|
const [form, setForm] = useState<OverviewFormData>(initial);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await onSave(form);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="task-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="task-modal-shell" style={{ width: 'min(480px, 94vw)' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="task-modal-head">
|
||||||
|
<h2 className="task-modal-title">개요 수정</h2>
|
||||||
|
<button type="button" onClick={onClose} className="task-modal-close" aria-label="닫기">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="task-modal-body">
|
||||||
|
<div className="task-form-fields">
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor="overview-desc">
|
||||||
|
개요
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="overview-desc"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
className="task-form-input task-form-textarea"
|
||||||
|
placeholder="프로젝트 개요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-field">
|
||||||
|
<label className="task-form-label" htmlFor="overview-detail">
|
||||||
|
상세내용
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="overview-detail"
|
||||||
|
value={form.detailDescription}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, detailDescription: e.target.value }))}
|
||||||
|
rows={5}
|
||||||
|
className="task-form-input task-form-textarea task-form-textarea--tall"
|
||||||
|
placeholder="상세 내용"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-form-actions">
|
||||||
|
<button type="button" onClick={onClose} className="task-form-btn task-form-btn--ghost">
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="task-form-btn task-form-btn--primary" disabled={saving}>
|
||||||
|
{saving ? '저장 중…' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,11 +41,14 @@ import { useTeamMembers } from '../../hooks/useTeamMembers';
|
|||||||
|
|
||||||
import { ResultPreview } from './ResultPreview';
|
import { ResultPreview } from './ResultPreview';
|
||||||
|
|
||||||
import { MilestoneTimeline } from './MilestoneTimeline';
|
import { MilestoneTimeline, type TimelineViewMode } from './MilestoneTimeline';
|
||||||
import { MilestoneContentList } from './MilestoneContentList';
|
import { MilestoneContentList } from './MilestoneContentList';
|
||||||
import { taskTimelineFallback } from '../../lib/milestoneTimeline';
|
import { taskTimelineFallback, sortMilestonesForTimeline } from '../../lib/milestoneTimeline';
|
||||||
import { serializePeriodEntries } from '../../lib/milestonePeriods';
|
import { serializePeriodEntries } from '../../lib/milestonePeriods';
|
||||||
|
|
||||||
|
import { formatQuarterShort } from '../../lib/boardCalendar';
|
||||||
|
import { getMilestonePeopleHeaderParts, getTaskPeopleHeaderParts } from '../../lib/teamStatus';
|
||||||
|
|
||||||
import type { Task, Milestone, FileRecord } from '../../types';
|
import type { Task, Milestone, FileRecord } from '../../types';
|
||||||
|
|
||||||
|
|
||||||
@@ -100,16 +103,28 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
const [tabSwitching, setTabSwitching] = useState(false);
|
const [tabSwitching, setTabSwitching] = useState(false);
|
||||||
|
|
||||||
|
const [timelineView, setTimelineView] = useState<TimelineViewMode>('focus');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
setActiveTaskId(initialTask.id);
|
setActiveTaskId(initialTask.id);
|
||||||
|
|
||||||
|
setTimelineView('focus');
|
||||||
|
|
||||||
}, [initialTask.id]);
|
}, [initialTask.id]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
setTimelineView('focus');
|
||||||
|
|
||||||
|
}, [activeTaskId]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const { data: activeTask = initialTask } = useQuery({
|
const { data: activeTask = initialTask } = useQuery({
|
||||||
queryKey: ['task', activeTaskId],
|
queryKey: ['task', activeTaskId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -233,6 +248,21 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
const selectedStage = milestones.find((m) => m.id === selectedStageId) ?? null;
|
const selectedStage = milestones.find((m) => m.id === selectedStageId) ?? null;
|
||||||
|
|
||||||
|
const headerPeople = useMemo(() => {
|
||||||
|
const fromStage = getMilestonePeopleHeaderParts(selectedStage);
|
||||||
|
if (fromStage.pmName || fromStage.assigneeNames.length > 0) return fromStage;
|
||||||
|
return getTaskPeopleHeaderParts(activeTask);
|
||||||
|
}, [selectedStage, activeTask]);
|
||||||
|
|
||||||
|
const headerProgressLabel = useMemo(() => {
|
||||||
|
if (selectedStage) return `${selectedStage.progress}%`;
|
||||||
|
if (milestones.length === 0) return '—';
|
||||||
|
const avg = Math.round(
|
||||||
|
milestones.reduce((sum, m) => sum + (m.progress ?? 0), 0) / milestones.length,
|
||||||
|
);
|
||||||
|
return `${avg}%`;
|
||||||
|
}, [selectedStage, milestones]);
|
||||||
|
|
||||||
const stageFiles = useMemo(
|
const stageFiles = useMemo(
|
||||||
|
|
||||||
() =>
|
() =>
|
||||||
@@ -257,6 +287,24 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const quarterRoutineTasks = useMemo(
|
||||||
|
() => quarterTasks.filter((t) => isRoutineTask(t.taskType)),
|
||||||
|
[quarterTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
quarterRoutineTasks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
milestones: sortMilestonesForTimeline((t.milestones ?? []) as Milestone[]),
|
||||||
|
})),
|
||||||
|
[quarterRoutineTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showGanttPanel = timelineView !== 'focus';
|
||||||
|
const showPortfolioFull = timelineView === 'portfolio';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const deleteStage = useMutation({
|
const deleteStage = useMutation({
|
||||||
@@ -475,11 +523,45 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-page-header__meta">
|
||||||
|
<span>
|
||||||
|
<span className="detail-page-header__meta-label">분기 </span>
|
||||||
|
<span className="detail-page-header__meta-value">{formatQuarterShort(activeTask.quarter)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="detail-page-header__divider" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<span className="detail-page-header__meta-label">업무 </span>
|
||||||
|
<span className="detail-page-header__meta-value">{milestones.length}건</span>
|
||||||
|
</span>
|
||||||
|
{(headerPeople.pmName || headerPeople.assigneeNames.length > 0) && (
|
||||||
|
<>
|
||||||
|
<span className="detail-page-header__divider" aria-hidden="true" />
|
||||||
|
{headerPeople.pmName && (
|
||||||
|
<span>
|
||||||
|
<span className="detail-page-header__meta-label">PM </span>
|
||||||
|
<span className="detail-page-header__meta-value">{headerPeople.pmName}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{headerPeople.assigneeNames.length > 0 && (
|
||||||
|
<span>
|
||||||
|
<span className="detail-page-header__meta-label">담당 </span>
|
||||||
|
<span className="detail-page-header__meta-value">
|
||||||
|
{headerPeople.assigneeNames.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="detail-page-header__badge">{headerProgressLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[1fr_3fr] grid-rows-1">
|
<div className={`grid min-h-0 flex-1 grid-rows-1 ${showPortfolioFull ? 'detail-page-grid--portfolio' : 'grid-cols-[1fr_3fr]'}`}>
|
||||||
|
|
||||||
|
{!showPortfolioFull && (
|
||||||
|
|
||||||
<aside className="detail-aside grid h-full min-h-0 grid-rows-[2fr_1fr] overflow-hidden">
|
<aside className="detail-aside grid h-full min-h-0 grid-rows-[2fr_1fr] overflow-hidden">
|
||||||
|
|
||||||
@@ -588,9 +670,13 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
|
||||||
|
<div className={`detail-main-panel flex h-full min-h-0 min-w-0 flex-col${showGanttPanel ? ' detail-panel--gantt' : ''}${showPortfolioFull ? ' detail-panel--portfolio' : ''}`}>
|
||||||
|
|
||||||
|
{timelineView === 'focus' && (
|
||||||
|
|
||||||
<ResultPreview
|
<ResultPreview
|
||||||
|
|
||||||
@@ -602,19 +688,29 @@ export function RoutineDetailView({
|
|||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<MilestoneTimeline
|
<MilestoneTimeline
|
||||||
|
|
||||||
|
variant="routine"
|
||||||
|
|
||||||
milestones={milestones}
|
milestones={milestones}
|
||||||
|
|
||||||
|
portfolioTasks={portfolioTasks}
|
||||||
|
|
||||||
fallback={taskTimelineFallback(activeTask)}
|
fallback={taskTimelineFallback(activeTask)}
|
||||||
|
|
||||||
selectedId={selectedStageId}
|
selectedId={selectedStageId}
|
||||||
|
|
||||||
onSelect={setSelectedStageId}
|
onSelect={setSelectedStageId}
|
||||||
|
|
||||||
preserveRowOrder
|
ownerTaskId={activeTaskId}
|
||||||
|
|
||||||
|
viewMode={timelineView}
|
||||||
|
|
||||||
|
onViewModeChange={setTimelineView}
|
||||||
|
|
||||||
emptyMessage="기간을 설정한 업무명만 타임라인에 표시됩니다."
|
emptyMessage="기간을 설정한 업무명만 타임라인에 표시됩니다."
|
||||||
|
|
||||||
|
|||||||
12
frontend/src/components/detail/stageFormTypes.ts
Normal file
12
frontend/src/components/detail/stageFormTypes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MilestonePeriodEntry } from '../types';
|
||||||
|
import type { MilestoneLink } from '../types';
|
||||||
|
|
||||||
|
export interface StageFormData {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
periodEntries: MilestonePeriodEntry[];
|
||||||
|
progress: number;
|
||||||
|
links: MilestoneLink[];
|
||||||
|
pmMemberId: string;
|
||||||
|
assigneeMemberIds: string[];
|
||||||
|
}
|
||||||
@@ -1,42 +1,80 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOARD_REF_DATE_KEY,
|
BOARD_QUARTER_KEY,
|
||||||
dateToQuarter,
|
dateToQuarter,
|
||||||
parseIsoDate,
|
parseQuarterKey,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
toIsoDate,
|
startOfWeekMonday,
|
||||||
} from '../lib/boardCalendar';
|
} from '../lib/boardCalendar';
|
||||||
|
|
||||||
export function useBoardReferenceDate() {
|
function readInitialQuarter(): string {
|
||||||
const [referenceDate, setReferenceDateState] = useState<Date>(() => {
|
const todayQuarter = dateToQuarter(new Date());
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(BOARD_REF_DATE_KEY);
|
const stored = localStorage.getItem(BOARD_QUARTER_KEY);
|
||||||
if (stored) {
|
if (parseQuarterKey(stored)) return stored!;
|
||||||
const parsed = parseIsoDate(stored);
|
localStorage.removeItem('eene-board-reference-date');
|
||||||
if (parsed) return startOfDay(parsed);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
return startOfDay(new Date());
|
return todayQuarter;
|
||||||
});
|
}
|
||||||
|
|
||||||
const setReferenceDate = useCallback((d: Date) => {
|
export function useBoardReferenceDate() {
|
||||||
const normalized = startOfDay(d);
|
const [selectedQuarter, setSelectedQuarterState] = useState(readInitialQuarter);
|
||||||
setReferenceDateState(normalized);
|
const [weekLensActive, setWeekLensActive] = useState(false);
|
||||||
|
const [referenceWeekMonday, setReferenceWeekMonday] = useState(() => startOfWeekMonday(new Date()));
|
||||||
|
|
||||||
|
const persistQuarter = useCallback((quarterKey: string) => {
|
||||||
|
setSelectedQuarterState(quarterKey);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(BOARD_REF_DATE_KEY, toIsoDate(normalized));
|
localStorage.setItem(BOARD_QUARTER_KEY, quarterKey);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const quarter = useMemo(() => dateToQuarter(referenceDate), [referenceDate]);
|
/** 분기 보기 — 카드 기본(제목·수행기간·개요·이슈) */
|
||||||
|
const selectQuarter = useCallback(
|
||||||
|
(quarterKey: string) => {
|
||||||
|
if (!parseQuarterKey(quarterKey)) return;
|
||||||
|
persistQuarter(quarterKey);
|
||||||
|
setWeekLensActive(false);
|
||||||
|
},
|
||||||
|
[persistQuarter],
|
||||||
|
);
|
||||||
|
|
||||||
const resetToToday = useCallback(() => {
|
/** 오늘이 포함된 분기 + 분기 보기 (첫 화면 복귀) */
|
||||||
setReferenceDate(startOfDay(new Date()));
|
const returnToCurrentQuarter = useCallback(() => {
|
||||||
}, [setReferenceDate]);
|
persistQuarter(dateToQuarter(new Date()));
|
||||||
|
setReferenceWeekMonday(startOfWeekMonday(new Date()));
|
||||||
|
setWeekLensActive(false);
|
||||||
|
}, [persistQuarter]);
|
||||||
|
|
||||||
return { referenceDate, setReferenceDate, quarter, resetToToday };
|
/** 주차 보기 — 카드에 해당 주 업무 1건 */
|
||||||
|
const selectWeek = useCallback(
|
||||||
|
(d: Date) => {
|
||||||
|
const monday = startOfWeekMonday(startOfDay(d));
|
||||||
|
setReferenceWeekMonday(monday);
|
||||||
|
setWeekLensActive(true);
|
||||||
|
persistQuarter(dateToQuarter(monday));
|
||||||
|
},
|
||||||
|
[persistQuarter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const referenceDate = useMemo(
|
||||||
|
() => (weekLensActive ? referenceWeekMonday : startOfWeekMonday(new Date())),
|
||||||
|
[weekLensActive, referenceWeekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
quarter: selectedQuarter,
|
||||||
|
selectedQuarter,
|
||||||
|
referenceDate,
|
||||||
|
referenceWeekMonday,
|
||||||
|
weekLensActive,
|
||||||
|
selectWeek,
|
||||||
|
selectQuarter,
|
||||||
|
returnToCurrentQuarter,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,45 @@ body,
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid tbody tr {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid tbody tr:hover {
|
||||||
|
background: var(--board-cal-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid tr.is-selected-week {
|
||||||
|
background: rgba(55, 161, 132, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-grid tr.is-selected-week .board-calendar-week-label-btn {
|
||||||
|
color: var(--board-cal-accent-dark);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-cell.is-outside {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-calendar-day-cell.is-today {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--board-cal-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--board-cal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.board-calendar-grid-wrap {
|
.board-calendar-grid-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const BOARD_REF_DATE_KEY = 'eene-board-reference-date';
|
export const BOARD_REF_DATE_KEY = 'eene-board-reference-date';
|
||||||
|
export const BOARD_QUARTER_KEY = 'eene-board-selected-quarter-v2';
|
||||||
|
|
||||||
|
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
|
||||||
|
|
||||||
export function startOfDay(d: Date): Date {
|
export function startOfDay(d: Date): Date {
|
||||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
@@ -25,6 +28,22 @@ export function quarterToLabel(quarter: string): string {
|
|||||||
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기 업무');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatQuarterShort(quarter: string): string {
|
||||||
|
return quarter.replace(/^(\d{4})-Q(\d)$/, '$1 $2분기');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuarterRangeLabel(quarter: string): string {
|
||||||
|
const m = quarter.match(/^\d{4}-Q([1-4])$/);
|
||||||
|
if (!m) return '';
|
||||||
|
return QUARTER_RANGE_LABELS[Number(m[1]) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatQuarterPillLabel(quarter: string): string {
|
||||||
|
const title = formatQuarterShort(quarter);
|
||||||
|
const range = formatQuarterRangeLabel(quarter);
|
||||||
|
return range ? `${title} · ${range}` : title;
|
||||||
|
}
|
||||||
|
|
||||||
export function weekOfMonthLabel(d: Date): string {
|
export function weekOfMonthLabel(d: Date): string {
|
||||||
return `${d.getMonth() + 1}월 ${Math.ceil(d.getDate() / 7)}주차`;
|
return `${d.getMonth() + 1}월 ${Math.ceil(d.getDate() / 7)}주차`;
|
||||||
}
|
}
|
||||||
@@ -33,17 +52,46 @@ export function quarterNumber(d: Date): number {
|
|||||||
return Math.floor(d.getMonth() / 3) + 1;
|
return Math.floor(d.getMonth() / 3) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatReferenceSummary(d: Date): string {
|
export function endOfWeekSunday(monday: Date): Date {
|
||||||
return `기준일 ${toIsoDate(d)} · ${quarterNumber(d)}분기`;
|
const d = startOfDay(monday);
|
||||||
|
d.setDate(d.getDate() + 6);
|
||||||
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated use formatReferenceSummary */
|
export function formatReferenceWeekSummary(d: Date): string {
|
||||||
|
const monday = startOfWeekMonday(d);
|
||||||
|
return `${weekOfMonthLabel(monday)} · ${quarterNumber(monday)}분기`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseQuarterKey(value: string | null | undefined): string | null {
|
||||||
|
if (!value || !/^\d{4}-Q[1-4]$/.test(value)) return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBoardCalendarPill(
|
||||||
|
selectedQuarter: string,
|
||||||
|
weekLensActive: boolean,
|
||||||
|
weekMonday: Date,
|
||||||
|
): string {
|
||||||
|
if (weekLensActive) {
|
||||||
|
const anchor = isSameWeek(new Date(), weekMonday) ? startOfDay(new Date()) : startOfWeekMonday(weekMonday);
|
||||||
|
return `기준일 ${toIsoDate(anchor)} · ${weekOfMonthLabel(weekMonday)}`;
|
||||||
|
}
|
||||||
|
return formatQuarterPillLabel(selectedQuarter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWeekRangeLabel(weekMonday: Date): string {
|
||||||
|
const start = startOfWeekMonday(weekMonday);
|
||||||
|
const end = endOfWeekSunday(start);
|
||||||
|
const fmt = (dt: Date) => `${dt.getMonth() + 1}/${dt.getDate()}`;
|
||||||
|
return `${fmt(start)} ~ ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use formatBoardCalendarPill */
|
||||||
export function formatReferencePill(d: Date): string {
|
export function formatReferencePill(d: Date): string {
|
||||||
return formatReferenceSummary(d);
|
return formatReferenceWeekSummary(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QUARTER_RANGE_LABELS = ['1.01~3.31', '4.01~6.30', '7.01~9.30', '10.01~12.31'] as const;
|
|
||||||
|
|
||||||
export function startOfWeekMonday(d: Date): Date {
|
export function startOfWeekMonday(d: Date): Date {
|
||||||
const x = startOfDay(d);
|
const x = startOfDay(d);
|
||||||
const dow = x.getDay();
|
const dow = x.getDay();
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
const CHANNEL_NAME = 'eee_dashboard';
|
const CHANNEL_NAME = 'eee_dashboard';
|
||||||
const DETAIL_WINDOW_NAME = 'eene_detail';
|
const DETAIL_WINDOW_NAME = 'eene_detail';
|
||||||
const SELECTED_TASK_KEY = 'eee_selected_task';
|
const SELECTED_TASK_KEY = 'eee_selected_task';
|
||||||
|
const SELECTED_STAGE_KEY = 'eee_selected_stage';
|
||||||
const PLACEMENT_STORAGE_KEY = 'eee_detail_window_placement';
|
const PLACEMENT_STORAGE_KEY = 'eee_detail_window_placement';
|
||||||
|
|
||||||
export type DualMonitorEvent =
|
export type DualMonitorEvent =
|
||||||
| { type: 'TASK_SELECTED'; taskId: string }
|
| { type: 'TASK_SELECTED'; taskId: string; stageId?: string | null }
|
||||||
| { type: 'TASK_DESELECTED' }
|
| { type: 'TASK_DESELECTED' }
|
||||||
| { type: 'REQUEST_SYNC' }
|
| { type: 'REQUEST_SYNC' }
|
||||||
| { type: 'REFRESH' };
|
| { type: 'REFRESH' };
|
||||||
@@ -206,15 +207,25 @@ export function isDualModeActive(): boolean {
|
|||||||
return dualModeActive && isDetailWindowOpen();
|
return dualModeActive && isDetailWindowOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistSelectedTask(taskId: string | null) {
|
function persistSelectedTask(taskId: string | null, stageId?: string | null) {
|
||||||
if (taskId) sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
|
if (taskId) {
|
||||||
else sessionStorage.removeItem(SELECTED_TASK_KEY);
|
sessionStorage.setItem(SELECTED_TASK_KEY, taskId);
|
||||||
|
if (stageId) sessionStorage.setItem(SELECTED_STAGE_KEY, stageId);
|
||||||
|
else sessionStorage.removeItem(SELECTED_STAGE_KEY);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem(SELECTED_TASK_KEY);
|
||||||
|
sessionStorage.removeItem(SELECTED_STAGE_KEY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPersistedTaskId(): string | null {
|
export function getPersistedTaskId(): string | null {
|
||||||
return sessionStorage.getItem(SELECTED_TASK_KEY);
|
return sessionStorage.getItem(SELECTED_TASK_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPersistedStageId(): string | null {
|
||||||
|
return sessionStorage.getItem(SELECTED_STAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function registerSyncProvider(fn: () => string | null): () => void {
|
export function registerSyncProvider(fn: () => string | null): () => void {
|
||||||
syncProvider = fn;
|
syncProvider = fn;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -242,14 +253,14 @@ function schedulePlacementRetries(win: Window, placement: WindowPlacement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function postTaskSelected(taskId: string) {
|
function postTaskSelected(taskId: string, stageId?: string | null) {
|
||||||
getChannel().postMessage({ type: 'TASK_SELECTED', taskId } satisfies DualMonitorEvent);
|
getChannel().postMessage({ type: 'TASK_SELECTED', taskId, stageId } satisfies DualMonitorEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleTaskSelected(taskId: string) {
|
function scheduleTaskSelected(taskId: string, stageId?: string | null) {
|
||||||
postTaskSelected(taskId);
|
postTaskSelected(taskId, stageId);
|
||||||
setTimeout(() => postTaskSelected(taskId), 500);
|
setTimeout(() => postTaskSelected(taskId, stageId), 500);
|
||||||
setTimeout(() => postTaskSelected(taskId), 1500);
|
setTimeout(() => postTaskSelected(taskId, stageId), 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null {
|
function openDetailWindowWithPlacement(placement: WindowPlacement): Window | null {
|
||||||
@@ -304,16 +315,18 @@ export async function openDetailWindow(onPopupClosed?: () => void): Promise<Wind
|
|||||||
}
|
}
|
||||||
|
|
||||||
const savedTaskId = getPersistedTaskId();
|
const savedTaskId = getPersistedTaskId();
|
||||||
if (savedTaskId) scheduleTaskSelected(savedTaskId);
|
const savedStageId = getPersistedStageId();
|
||||||
|
if (savedTaskId) scheduleTaskSelected(savedTaskId, savedStageId);
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 업무 선택 — await getScreenDetails(권한) 후 팝업 open */
|
/** 업무 선택 — await getScreenDetails(권한) 후 팝업 open */
|
||||||
export async function sendTaskSelected(
|
export async function sendTaskSelected(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
stageId?: string | null,
|
||||||
onPopupClosed?: () => void,
|
onPopupClosed?: () => void,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
persistSelectedTask(taskId);
|
persistSelectedTask(taskId, stageId);
|
||||||
|
|
||||||
if (!isDetailWindowOpen()) {
|
if (!isDetailWindowOpen()) {
|
||||||
dualModeActive = true;
|
dualModeActive = true;
|
||||||
@@ -322,14 +335,14 @@ export async function sendTaskSelected(
|
|||||||
dualModeActive = false;
|
dualModeActive = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
scheduleTaskSelected(taskId);
|
scheduleTaskSelected(taskId, stageId);
|
||||||
if (lastPlacementIssue) {
|
if (lastPlacementIssue) {
|
||||||
window.alert(lastPlacementIssue);
|
window.alert(lastPlacementIssue);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleTaskSelected(taskId);
|
scheduleTaskSelected(taskId, stageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
detailWindow!.focus();
|
detailWindow!.focus();
|
||||||
@@ -357,7 +370,7 @@ export function requestDetailSync(): void {
|
|||||||
|
|
||||||
function respondToSyncRequest() {
|
function respondToSyncRequest() {
|
||||||
const id = syncProvider?.() ?? getPersistedTaskId();
|
const id = syncProvider?.() ?? getPersistedTaskId();
|
||||||
if (id) postTaskSelected(id);
|
if (id) postTaskSelected(id, getPersistedStageId());
|
||||||
else getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
|
else getChannel().postMessage({ type: 'TASK_DESELECTED' } satisfies DualMonitorEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { quarterDateBounds } from './hubSchedule';
|
import { quarterEndDate, quarterStartDate } from './boardCalendar';
|
||||||
|
|
||||||
export interface TimelineMilestoneInput {
|
export interface TimelineMilestoneInput {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,14 +43,19 @@ export interface MilestoneTimelineModel {
|
|||||||
rangeEndLabel: string;
|
rangeEndLabel: string;
|
||||||
ticks: TimelineTick[];
|
ticks: TimelineTick[];
|
||||||
rows: TimelineRow[];
|
rows: TimelineRow[];
|
||||||
|
todayLeftPct?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
const RANGE_PAD_DAYS = 2;
|
const RANGE_PAD_DAYS = 2;
|
||||||
/** 하루·단일일정 막대가 타임라인에서 묻히지 않도록 */
|
/** 하루·단일일정 막대가 타임라인에서 묻히지 않도록 */
|
||||||
const MIN_BAR_WIDTH_PCT = 2;
|
const MIN_BAR_WIDTH_PCT = 2;
|
||||||
|
/** 간트 진행률(18px) 뱃지가 잘리지 않도록 */
|
||||||
|
const MIN_GANTT_BAR_WIDTH_PCT = 11;
|
||||||
/** 오늘 눈금과 겹치는 기존 날짜 라벨 제거 (주간 눈금 등) */
|
/** 오늘 눈금과 겹치는 기존 날짜 라벨 제거 (주간 눈금 등) */
|
||||||
const TODAY_TICK_MIN_GAP_PCT = 3.2;
|
const TODAY_TICK_MIN_GAP_PCT = 3.2;
|
||||||
|
/** 이 이상이면 일/주 단위 대신 월별 눈금 */
|
||||||
|
const MONTH_TICK_MIN_DAYS = 28;
|
||||||
|
|
||||||
export function taskTimelineFallback(task: {
|
export function taskTimelineFallback(task: {
|
||||||
startDate?: string | null;
|
startDate?: string | null;
|
||||||
@@ -97,10 +102,138 @@ function collectMilestoneTimes(milestones: TimelineMilestoneInput[]): number[] {
|
|||||||
return times;
|
return times;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function quarterRange(quarterKey: string): { start: Date; end: Date } {
|
||||||
|
return {
|
||||||
|
start: startOfDay(quarterStartDate(quarterKey)),
|
||||||
|
end: startOfDay(quarterEndDate(quarterKey)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_MONTH_COUNT = 3;
|
||||||
|
|
||||||
|
function monthStart(d: Date): Date {
|
||||||
|
return startOfDay(new Date(d.getFullYear(), d.getMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthEnd(d: Date): Date {
|
||||||
|
return startOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(monthAnchor: Date, delta: number): Date {
|
||||||
|
return new Date(monthAnchor.getFullYear(), monthAnchor.getMonth() + delta, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthSpanCount(firstMonth: Date, lastMonth: Date): number {
|
||||||
|
return (lastMonth.getFullYear() - firstMonth.getFullYear()) * 12 + (lastMonth.getMonth() - firstMonth.getMonth()) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 월 초 기준 정확히 3개월 */
|
||||||
|
function threeMonthWindowFrom(windowStart: Date): { start: Date; end: Date } {
|
||||||
|
const start = monthStart(windowStart);
|
||||||
|
const end = monthEnd(addMonths(start, TIMELINE_MONTH_COUNT - 1));
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 타임라인 — 항상 3개월 분할
|
||||||
|
* - 1달 업무: 앞·뒤 1달씩 (±1)
|
||||||
|
* - 2달 업무: 앞 1달 + 업무 2달
|
||||||
|
* - 3달 업무: 업무 3달 그대로 (여유 달 없음)
|
||||||
|
* - 4달 이상: 오늘(또는 기준일)이 포함된 3달, 업무 범위 안에서 이동
|
||||||
|
*/
|
||||||
|
function resolveThreeMonthWindow(
|
||||||
|
coreStart: Date,
|
||||||
|
coreEnd: Date,
|
||||||
|
refDate: Date = new Date(),
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const firstMonth = monthStart(coreStart);
|
||||||
|
const lastMonth = monthStart(coreEnd);
|
||||||
|
const spanMonths = monthSpanCount(firstMonth, lastMonth);
|
||||||
|
|
||||||
|
if (spanMonths <= 1) {
|
||||||
|
return threeMonthWindowFrom(addMonths(firstMonth, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spanMonths === 2) {
|
||||||
|
return threeMonthWindowFrom(addMonths(firstMonth, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spanMonths === 3) {
|
||||||
|
return threeMonthWindowFrom(firstMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ref = startOfDay(refDate);
|
||||||
|
const refClamped =
|
||||||
|
ref.getTime() < coreStart.getTime() ? coreStart : ref.getTime() > coreEnd.getTime() ? coreEnd : ref;
|
||||||
|
|
||||||
|
let windowStart = monthStart(refClamped);
|
||||||
|
const earliest = firstMonth;
|
||||||
|
const latestStart = addMonths(lastMonth, -(TIMELINE_MONTH_COUNT - 1));
|
||||||
|
|
||||||
|
if (windowStart < earliest) windowStart = earliest;
|
||||||
|
if (windowStart > latestStart) windowStart = latestStart;
|
||||||
|
|
||||||
|
return threeMonthWindowFrom(windowStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineFocusRange {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedMilestoneSpan(
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
selectedId: string | null | undefined,
|
||||||
|
): { start: Date; end: Date } | null {
|
||||||
|
if (!selectedId) return null;
|
||||||
|
const m = milestones.find((x) => x.id === selectedId);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const spans = milestonePeriodSpans(m);
|
||||||
|
if (spans.length === 0) {
|
||||||
|
if (m.startDate || m.dueDate) {
|
||||||
|
const start = parseDay(m.startDate ?? m.dueDate!);
|
||||||
|
const end = parseDay(m.dueDate ?? m.startDate!);
|
||||||
|
return { start, end: end.getTime() < start.getTime() ? start : end };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = Math.min(...spans.map((s) => s.startMs));
|
||||||
|
const endMs = Math.max(...spans.map((s) => s.endMs));
|
||||||
|
return { start: new Date(startMs), end: new Date(endMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택 업무 기준 타임라인 — 항상 3개월
|
||||||
|
*/
|
||||||
|
function resolveTimelineFocusRange(
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
selectedId: string | null | undefined,
|
||||||
|
fallback: TimelineRangeFallback,
|
||||||
|
): TimelineFocusRange {
|
||||||
|
const span = getSelectedMilestoneSpan(milestones, selectedId);
|
||||||
|
|
||||||
|
if (span) {
|
||||||
|
return resolveThreeMonthWindow(span.start, span.end);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallback.quarter) {
|
||||||
|
return quarterRange(fallback.quarter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveThreeMonthWindow(new Date(), new Date());
|
||||||
|
}
|
||||||
|
|
||||||
function computeRange(
|
function computeRange(
|
||||||
milestones: TimelineMilestoneInput[],
|
milestones: TimelineMilestoneInput[],
|
||||||
fallback: TimelineRangeFallback,
|
fallback: TimelineRangeFallback,
|
||||||
|
options?: { selectedId?: string | null; focusQuarter?: boolean },
|
||||||
): { start: Date; end: Date } | null {
|
): { start: Date; end: Date } | null {
|
||||||
|
if (options?.focusQuarter !== false) {
|
||||||
|
return resolveTimelineFocusRange(milestones, options?.selectedId, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
const times = collectMilestoneTimes(milestones);
|
const times = collectMilestoneTimes(milestones);
|
||||||
|
|
||||||
if (times.length >= 1) {
|
if (times.length >= 1) {
|
||||||
@@ -121,17 +254,50 @@ function computeRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fallback.quarter) {
|
if (fallback.quarter) {
|
||||||
const { min, max } = quarterDateBounds(fallback.quarter);
|
const range = quarterRange(fallback.quarter);
|
||||||
return { start: parseDay(min), end: parseDay(max) };
|
return { start: range.start, end: range.end };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
|
function buildMonthTicks(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
|
||||||
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
|
||||||
|
const now = startOfDay(new Date());
|
||||||
|
const todayInRange = now >= rangeStart && now <= rangeEnd;
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
const ticks: TimelineTick[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < TIMELINE_MONTH_COUNT; i++) {
|
||||||
|
const d = new Date(rangeStart.getFullYear(), rangeStart.getMonth() + i, 1);
|
||||||
|
ticks.push({
|
||||||
|
label: `${d.getMonth() + 1}월`,
|
||||||
|
leftPct: ((d.getTime() - rangeStart.getTime()) / rangeMs) * 100,
|
||||||
|
isToday: todayInRange && d.getTime() === nowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
|
||||||
|
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
|
||||||
|
ticks.push({
|
||||||
|
label: fmtTimelineDayLabel(now),
|
||||||
|
leftPct: todayLeftPct,
|
||||||
|
isToday: true,
|
||||||
|
});
|
||||||
|
ticks.sort((a, b) => a.leftPct - b.leftPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicks(rangeStart: Date, rangeEnd: Date, focusQuarter?: boolean): TimelineTick[] {
|
||||||
|
if (focusQuarter !== false) {
|
||||||
|
return buildMonthTicks(rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
|
||||||
const totalDays = Math.ceil(rangeMs / DAY_MS) + 1;
|
const totalDays = Math.ceil(rangeMs / DAY_MS) + 1;
|
||||||
const stepDays = totalDays > 45 ? 7 : totalDays > 28 ? 2 : 1;
|
const stepDays = totalDays > 14 ? 7 : totalDays > 7 ? 2 : 1;
|
||||||
const now = startOfDay(new Date());
|
const now = startOfDay(new Date());
|
||||||
const todayInRange = now >= rangeStart && now <= rangeEnd;
|
const todayInRange = now >= rangeStart && now <= rangeEnd;
|
||||||
const nowMs = now.getTime();
|
const nowMs = now.getTime();
|
||||||
@@ -193,11 +359,46 @@ function milestonePeriodSpans(m: TimelineMilestoneInput): Array<{ startMs: numbe
|
|||||||
return spans;
|
return spans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 간트 행 순서 — 과거(상단) → 최신(하단) */
|
||||||
|
function pickTimelineSortMs(m: TimelineMilestoneInput): number {
|
||||||
|
const spans = milestonePeriodSpans(m);
|
||||||
|
if (spans.length > 0) return Math.min(...spans.map((s) => s.startMs));
|
||||||
|
if (m.startDate) return parseDay(m.startDate).getTime();
|
||||||
|
if (m.dueDate) return parseDay(m.dueDate).getTime();
|
||||||
|
if (m.createdAt) return new Date(m.createdAt).getTime();
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortMilestonesForTimeline(milestones: TimelineMilestoneInput[]): TimelineMilestoneInput[] {
|
||||||
|
return [...milestones].sort((a, b) => {
|
||||||
|
const diff = pickTimelineSortMs(a) - pickTimelineSortMs(b);
|
||||||
|
if (diff !== 0) return diff;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 분기 전체 간트 — 프로젝트명 · 업무명 라벨 */
|
||||||
|
export function flattenTasksToTimelineMilestones(
|
||||||
|
tasks: Array<{ id: string; title: string; milestones?: TimelineMilestoneInput[] }>,
|
||||||
|
): TimelineMilestoneInput[] {
|
||||||
|
const items: TimelineMilestoneInput[] = [];
|
||||||
|
for (const t of tasks) {
|
||||||
|
for (const m of t.milestones ?? []) {
|
||||||
|
items.push({
|
||||||
|
...m,
|
||||||
|
title: `${t.title} · ${m.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortMilestonesForTimeline(items);
|
||||||
|
}
|
||||||
|
|
||||||
function buildRows(
|
function buildRows(
|
||||||
ordered: TimelineMilestoneInput[],
|
ordered: TimelineMilestoneInput[],
|
||||||
rangeStart: Date,
|
rangeStart: Date,
|
||||||
rangeEnd: Date,
|
rangeEnd: Date,
|
||||||
hideUndatedBars?: boolean,
|
hideUndatedBars?: boolean,
|
||||||
|
minBarWidthPct: number = MIN_BAR_WIDTH_PCT,
|
||||||
): TimelineRow[] {
|
): TimelineRow[] {
|
||||||
const items =
|
const items =
|
||||||
hideUndatedBars === false
|
hideUndatedBars === false
|
||||||
@@ -205,7 +406,8 @@ function buildRows(
|
|||||||
: ordered.filter((m) => milestoneHasDates(m));
|
: ordered.filter((m) => milestoneHasDates(m));
|
||||||
|
|
||||||
const rangeStartMs = rangeStart.getTime();
|
const rangeStartMs = rangeStart.getTime();
|
||||||
const rangeMs = Math.max(rangeEnd.getTime() - rangeStartMs, DAY_MS);
|
const rangeEndMs = rangeEnd.getTime();
|
||||||
|
const rangeMs = Math.max(rangeEndMs - rangeStartMs, DAY_MS);
|
||||||
|
|
||||||
const rows: TimelineRow[] = [];
|
const rows: TimelineRow[] = [];
|
||||||
|
|
||||||
@@ -214,14 +416,17 @@ function buildRows(
|
|||||||
if (spans.length === 0) continue;
|
if (spans.length === 0) continue;
|
||||||
|
|
||||||
spans.forEach((span, index) => {
|
spans.forEach((span, index) => {
|
||||||
const { startMs, endMs } = span;
|
const clipStartMs = Math.max(span.startMs, rangeStartMs);
|
||||||
const spanMs = endMs - startMs;
|
const clipEndMs = Math.min(span.endMs, rangeEndMs);
|
||||||
|
if (clipEndMs < clipStartMs) return;
|
||||||
|
|
||||||
|
const spanMs = clipEndMs - clipStartMs;
|
||||||
const isPoint = spanMs === 0;
|
const isPoint = spanMs === 0;
|
||||||
|
|
||||||
let widthPct = isPoint ? MIN_BAR_WIDTH_PCT : (spanMs / rangeMs) * 100;
|
let widthPct = isPoint ? minBarWidthPct : (spanMs / rangeMs) * 100;
|
||||||
widthPct = Math.max(MIN_BAR_WIDTH_PCT, widthPct);
|
widthPct = Math.max(minBarWidthPct, widthPct);
|
||||||
|
|
||||||
let leftPct = ((startMs - rangeStartMs) / rangeMs) * 100;
|
let leftPct = ((clipStartMs - rangeStartMs) / rangeMs) * 100;
|
||||||
if (isPoint) leftPct -= widthPct / 2;
|
if (isPoint) leftPct -= widthPct / 2;
|
||||||
leftPct = Math.max(0, Math.min(100 - widthPct, leftPct));
|
leftPct = Math.max(0, Math.min(100 - widthPct, leftPct));
|
||||||
|
|
||||||
@@ -242,26 +447,198 @@ function buildRows(
|
|||||||
export function buildMilestoneTimeline(
|
export function buildMilestoneTimeline(
|
||||||
milestones: TimelineMilestoneInput[],
|
milestones: TimelineMilestoneInput[],
|
||||||
fallback: TimelineRangeFallback = {},
|
fallback: TimelineRangeFallback = {},
|
||||||
options?: { preserveOrder?: boolean; hideUndatedBars?: boolean },
|
options?: {
|
||||||
|
hideUndatedBars?: boolean;
|
||||||
|
selectedId?: string | null;
|
||||||
|
focusQuarter?: boolean;
|
||||||
|
viewport?: { start: Date; end: Date };
|
||||||
|
ganttBars?: boolean;
|
||||||
|
},
|
||||||
): MilestoneTimelineModel | null {
|
): MilestoneTimelineModel | null {
|
||||||
if (milestones.length === 0) return null;
|
if (milestones.length === 0) return null;
|
||||||
|
|
||||||
const range = computeRange(milestones, fallback);
|
const focusQuarter = options?.focusQuarter !== false && !options?.viewport;
|
||||||
|
const range =
|
||||||
|
options?.viewport ??
|
||||||
|
computeRange(milestones, fallback, {
|
||||||
|
selectedId: options?.selectedId,
|
||||||
|
focusQuarter,
|
||||||
|
});
|
||||||
if (!range) return null;
|
if (!range) return null;
|
||||||
|
|
||||||
const ordered = options?.preserveOrder
|
const ordered = sortMilestonesForTimeline(milestones);
|
||||||
? [...milestones]
|
|
||||||
: [...milestones].sort((a, b) => a.order - b.order);
|
|
||||||
|
|
||||||
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars);
|
const minBarWidthPct = options?.ganttBars ? MIN_GANTT_BAR_WIDTH_PCT : MIN_BAR_WIDTH_PCT;
|
||||||
if (rows.length === 0) return null;
|
const rows = buildRows(ordered, range.start, range.end, options?.hideUndatedBars, minBarWidthPct);
|
||||||
|
if (rows.length === 0 && !focusQuarter && !options?.viewport) return null;
|
||||||
|
|
||||||
const ticks = buildTicks(range.start, range.end);
|
const useMonthTicks =
|
||||||
|
focusQuarter || preferMonthTicks(range.start, range.end);
|
||||||
|
const ticks = useMonthTicks
|
||||||
|
? buildMonthTicksInRange(range.start, range.end)
|
||||||
|
: buildTicks(range.start, range.end, false);
|
||||||
|
const todayTick = ticks.find((tick) => tick.isToday);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rangeStartLabel: fmtTimelineDayLabel(range.start),
|
rangeStartLabel: fmtTimelineDayLabel(range.start),
|
||||||
rangeEndLabel: fmtTimelineDayLabel(range.end),
|
rangeEndLabel: fmtTimelineDayLabel(range.end),
|
||||||
ticks,
|
ticks,
|
||||||
rows,
|
rows,
|
||||||
|
todayLeftPct: todayTick?.leftPct ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PortfolioTaskGroup {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
milestones: TimelineMilestoneInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeFullTimelineRange(
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
fallback: TimelineRangeFallback = {},
|
||||||
|
): { start: Date; end: Date } | null {
|
||||||
|
return computeRange(milestones, fallback, { focusQuarter: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimelineViewportMode = 'focus' | 'project' | 'portfolio';
|
||||||
|
|
||||||
|
/** 보기 모드별 차트 초기 viewport (pan/zoom 데이터 범위는 computeFullTimelineRange) */
|
||||||
|
export function resolveTimelineInitialViewport(
|
||||||
|
viewMode: TimelineViewportMode,
|
||||||
|
milestones: TimelineMilestoneInput[],
|
||||||
|
fallback: TimelineRangeFallback = {},
|
||||||
|
selectedId?: string | null,
|
||||||
|
): { start: Date; end: Date } | null {
|
||||||
|
const dataRange = computeFullTimelineRange(milestones, fallback);
|
||||||
|
if (!dataRange) return null;
|
||||||
|
|
||||||
|
if (viewMode === 'project') {
|
||||||
|
return dataRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'focus') {
|
||||||
|
const focusRange = computeRange(milestones, fallback, {
|
||||||
|
selectedId,
|
||||||
|
focusQuarter: true,
|
||||||
|
});
|
||||||
|
return focusRange ?? resolvePortfolioInitialViewport(dataRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvePortfolioInitialViewport(dataRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePortfolioInitialViewport(
|
||||||
|
dataRange: { start: Date; end: Date },
|
||||||
|
refDate: Date = new Date(),
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
return resolveThreeMonthWindow(dataRange.start, dataRange.end, refDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampPortfolioViewport(
|
||||||
|
viewport: { start: Date; end: Date },
|
||||||
|
dataRange: { start: Date; end: Date },
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const dataStartMs = dataRange.start.getTime();
|
||||||
|
const dataEndMs = dataRange.end.getTime();
|
||||||
|
const dataSpanMs = Math.max(dataEndMs - dataStartMs, DAY_MS);
|
||||||
|
|
||||||
|
let windowMs = Math.max(viewport.end.getTime() - viewport.start.getTime(), 7 * DAY_MS);
|
||||||
|
windowMs = Math.min(windowMs, dataSpanMs);
|
||||||
|
|
||||||
|
let startMs = viewport.start.getTime();
|
||||||
|
let endMs = startMs + windowMs;
|
||||||
|
|
||||||
|
if (endMs > dataEndMs) {
|
||||||
|
endMs = dataEndMs;
|
||||||
|
startMs = endMs - windowMs;
|
||||||
|
}
|
||||||
|
if (startMs < dataStartMs) {
|
||||||
|
startMs = dataStartMs;
|
||||||
|
endMs = startMs + windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
endMs = Math.min(endMs, dataEndMs);
|
||||||
|
startMs = Math.max(startMs, dataStartMs);
|
||||||
|
|
||||||
|
return { start: new Date(startMs), end: new Date(endMs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function panPortfolioViewport(
|
||||||
|
viewport: { start: Date; end: Date },
|
||||||
|
dataRange: { start: Date; end: Date },
|
||||||
|
deltaMs: number,
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
return clampPortfolioViewport(
|
||||||
|
{
|
||||||
|
start: new Date(viewport.start.getTime() + deltaMs),
|
||||||
|
end: new Date(viewport.end.getTime() + deltaMs),
|
||||||
|
},
|
||||||
|
dataRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zoomPortfolioViewport(
|
||||||
|
viewport: { start: Date; end: Date },
|
||||||
|
dataRange: { start: Date; end: Date },
|
||||||
|
factor: number,
|
||||||
|
anchorPct: number,
|
||||||
|
): { start: Date; end: Date } {
|
||||||
|
const startMs = viewport.start.getTime();
|
||||||
|
const endMs = viewport.end.getTime();
|
||||||
|
const windowMs = Math.max(endMs - startMs, DAY_MS);
|
||||||
|
const dataSpanMs = Math.max(dataRange.end.getTime() - dataRange.start.getTime(), DAY_MS);
|
||||||
|
|
||||||
|
const minWindow = 7 * DAY_MS;
|
||||||
|
const newWindow = Math.min(dataSpanMs, Math.max(minWindow, windowMs * factor));
|
||||||
|
const anchorMs = startMs + windowMs * Math.min(1, Math.max(0, anchorPct));
|
||||||
|
const newStartMs = anchorMs - newWindow * anchorPct;
|
||||||
|
|
||||||
|
return clampPortfolioViewport(
|
||||||
|
{ start: new Date(newStartMs), end: new Date(newStartMs + newWindow) },
|
||||||
|
dataRange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferMonthTicks(rangeStart: Date, rangeEnd: Date): boolean {
|
||||||
|
const days = (rangeEnd.getTime() - rangeStart.getTime()) / DAY_MS;
|
||||||
|
if (days >= MONTH_TICK_MIN_DAYS) return true;
|
||||||
|
if (monthStart(rangeStart).getTime() !== monthStart(rangeEnd).getTime()) return true;
|
||||||
|
return days >= 14;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseMonthTicks(rangeStart: Date, rangeEnd: Date): boolean {
|
||||||
|
return preferMonthTicks(rangeStart, rangeEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthTicksInRange(rangeStart: Date, rangeEnd: Date): TimelineTick[] {
|
||||||
|
const rangeMs = Math.max(rangeEnd.getTime() - rangeStart.getTime(), DAY_MS);
|
||||||
|
const now = startOfDay(new Date());
|
||||||
|
const todayInRange = now >= rangeStart && now <= rangeEnd;
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
const ticks: TimelineTick[] = [];
|
||||||
|
|
||||||
|
for (let cursor = monthStart(rangeStart); cursor <= rangeEnd; cursor = addMonths(cursor, 1)) {
|
||||||
|
ticks.push({
|
||||||
|
label: `${cursor.getMonth() + 1}월`,
|
||||||
|
leftPct: ((cursor.getTime() - rangeStart.getTime()) / rangeMs) * 100,
|
||||||
|
isToday: todayInRange && cursor.getTime() === nowMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todayInRange && !ticks.some((tick) => tick.isToday)) {
|
||||||
|
const todayLeftPct = ((nowMs - rangeStart.getTime()) / rangeMs) * 100;
|
||||||
|
ticks.push({
|
||||||
|
label: fmtTimelineDayLabel(now),
|
||||||
|
leftPct: todayLeftPct,
|
||||||
|
isToday: true,
|
||||||
|
});
|
||||||
|
ticks.sort((a, b) => a.leftPct - b.leftPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function milestoneHasTimelineDates(m: TimelineMilestoneInput): boolean {
|
||||||
|
return milestoneHasDates(m);
|
||||||
|
}
|
||||||
|
|||||||
221
frontend/src/lib/milestoneWeekFocus.ts
Normal file
221
frontend/src/lib/milestoneWeekFocus.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import {
|
||||||
|
endOfWeekSunday,
|
||||||
|
parseIsoDate,
|
||||||
|
quarterEndDate,
|
||||||
|
quarterStartDate,
|
||||||
|
startOfDay,
|
||||||
|
} from './boardCalendar';
|
||||||
|
import { fmtPeriodRange, parseMilestonePeriods } from './milestonePeriods';
|
||||||
|
import type { Milestone, Task } from '../types';
|
||||||
|
|
||||||
|
export interface TaskWeekFocus {
|
||||||
|
milestoneId: string;
|
||||||
|
milestoneTitle: string;
|
||||||
|
periodLabel: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskFocusOptions {
|
||||||
|
weekMonday: Date;
|
||||||
|
weekLensActive?: boolean;
|
||||||
|
quarter?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskWithMilestones = Task & { milestones?: Milestone[] };
|
||||||
|
|
||||||
|
interface PeriodSpan {
|
||||||
|
milestoneId: string;
|
||||||
|
milestoneTitle: string;
|
||||||
|
periodLabel: string;
|
||||||
|
progress: number;
|
||||||
|
startMs: number;
|
||||||
|
endMs: number;
|
||||||
|
sortDue: string;
|
||||||
|
sortStart: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodOverlapsRange(
|
||||||
|
startIso: string | null | undefined,
|
||||||
|
endIso: string | null | undefined,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): boolean {
|
||||||
|
const startMs = startIso ? parseIsoDate(startIso)?.getTime() : null;
|
||||||
|
const endMs = endIso ? parseIsoDate(endIso)?.getTime() : null;
|
||||||
|
if (startMs == null && endMs == null) return false;
|
||||||
|
|
||||||
|
const spanStart = startMs ?? endMs!;
|
||||||
|
const spanEnd = endMs ?? startMs!;
|
||||||
|
const rs = startOfDay(rangeStart).getTime();
|
||||||
|
const re = startOfDay(rangeEnd).getTime();
|
||||||
|
return spanStart <= re && spanEnd >= rs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPeriodSpans(task: TaskWithMilestones): PeriodSpan[] {
|
||||||
|
const spans: PeriodSpan[] = [];
|
||||||
|
|
||||||
|
for (const ms of task.milestones ?? []) {
|
||||||
|
const title = ms.title?.trim();
|
||||||
|
if (!title) continue;
|
||||||
|
|
||||||
|
const periods = parseMilestonePeriods(ms);
|
||||||
|
if (periods.length > 0) {
|
||||||
|
for (const period of periods) {
|
||||||
|
if (!period.startDate && !period.dueDate) continue;
|
||||||
|
const startMs = period.startDate
|
||||||
|
? (parseIsoDate(period.startDate)?.getTime() ?? Number.NaN)
|
||||||
|
: Number.NaN;
|
||||||
|
const endMs = period.dueDate
|
||||||
|
? (parseIsoDate(period.dueDate)?.getTime() ?? Number.NaN)
|
||||||
|
: Number.NaN;
|
||||||
|
const resolvedStart = Number.isFinite(startMs) ? startMs : endMs;
|
||||||
|
const resolvedEnd = Number.isFinite(endMs) ? endMs : startMs;
|
||||||
|
if (!Number.isFinite(resolvedStart) || !Number.isFinite(resolvedEnd)) continue;
|
||||||
|
spans.push({
|
||||||
|
milestoneId: ms.id,
|
||||||
|
milestoneTitle: title,
|
||||||
|
periodLabel: fmtPeriodRange(period) || '—',
|
||||||
|
progress: ms.progress ?? 0,
|
||||||
|
startMs: Math.min(resolvedStart, resolvedEnd),
|
||||||
|
endMs: Math.max(resolvedStart, resolvedEnd),
|
||||||
|
sortDue: period.dueDate || period.startDate || '9999-12-31',
|
||||||
|
sortStart: period.startDate || period.dueDate || '9999-12-31',
|
||||||
|
order: ms.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ms.startDate && !ms.dueDate) continue;
|
||||||
|
const startMs = ms.startDate ? (parseIsoDate(ms.startDate)?.getTime() ?? Number.NaN) : Number.NaN;
|
||||||
|
const endMs = ms.dueDate ? (parseIsoDate(ms.dueDate)?.getTime() ?? Number.NaN) : Number.NaN;
|
||||||
|
const resolvedStart = Number.isFinite(startMs) ? startMs : endMs;
|
||||||
|
const resolvedEnd = Number.isFinite(endMs) ? endMs : startMs;
|
||||||
|
if (!Number.isFinite(resolvedStart) || !Number.isFinite(resolvedEnd)) continue;
|
||||||
|
spans.push({
|
||||||
|
milestoneId: ms.id,
|
||||||
|
milestoneTitle: title,
|
||||||
|
periodLabel:
|
||||||
|
fmtPeriodRange({ startDate: ms.startDate ?? '', dueDate: ms.dueDate ?? '' }) || '—',
|
||||||
|
progress: ms.progress ?? 0,
|
||||||
|
startMs: Math.min(resolvedStart, resolvedEnd),
|
||||||
|
endMs: Math.max(resolvedStart, resolvedEnd),
|
||||||
|
sortDue: ms.dueDate || ms.startDate || '9999-12-31',
|
||||||
|
sortStart: ms.startDate || ms.dueDate || '9999-12-31',
|
||||||
|
order: ms.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestSpan(spans: PeriodSpan[]): PeriodSpan | null {
|
||||||
|
if (spans.length === 0) return null;
|
||||||
|
const sorted = [...spans].sort((a, b) => {
|
||||||
|
const dueCmp = a.sortDue.localeCompare(b.sortDue);
|
||||||
|
if (dueCmp !== 0) return dueCmp;
|
||||||
|
const startCmp = a.sortStart.localeCompare(b.sortStart);
|
||||||
|
if (startCmp !== 0) return startCmp;
|
||||||
|
return a.order - b.order;
|
||||||
|
});
|
||||||
|
return sorted[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spansOverlappingRange(
|
||||||
|
task: TaskWithMilestones,
|
||||||
|
rangeStart: Date,
|
||||||
|
rangeEnd: Date,
|
||||||
|
): PeriodSpan[] {
|
||||||
|
return collectPeriodSpans(task).filter((span) => {
|
||||||
|
const rs = startOfDay(rangeStart).getTime();
|
||||||
|
const re = startOfDay(rangeEnd).getTime();
|
||||||
|
return span.startMs <= re && span.endMs >= rs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spanToFocus(span: PeriodSpan): TaskWeekFocus {
|
||||||
|
return {
|
||||||
|
milestoneId: span.milestoneId,
|
||||||
|
milestoneTitle: span.milestoneTitle,
|
||||||
|
periodLabel: span.periodLabel,
|
||||||
|
progress: span.progress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선택 주(월요일 기준)에 해당하는 업무 1건 — 겹치면 마감이 가장 가까운 것 */
|
||||||
|
export function resolveTaskWeekFocus(
|
||||||
|
task: TaskWithMilestones,
|
||||||
|
weekMonday: Date,
|
||||||
|
): TaskWeekFocus | null {
|
||||||
|
const weekStart = startOfDay(weekMonday);
|
||||||
|
const weekEnd = endOfWeekSunday(weekMonday);
|
||||||
|
const best = pickBestSpan(spansOverlappingRange(task, weekStart, weekEnd));
|
||||||
|
return best ? spanToFocus(best) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTaskWeekMilestoneId(
|
||||||
|
task: TaskWithMilestones,
|
||||||
|
weekMonday: Date,
|
||||||
|
): string | null {
|
||||||
|
return resolveTaskWeekFocus(task, weekMonday)?.milestoneId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTaskQuarterFocus(task: TaskWithMilestones, quarter: string): TaskWeekFocus | null {
|
||||||
|
const rangeStart = startOfDay(quarterStartDate(quarter));
|
||||||
|
const rangeEnd = startOfDay(quarterEndDate(quarter));
|
||||||
|
const best = pickBestSpan(spansOverlappingRange(task, rangeStart, rangeEnd));
|
||||||
|
return best ? spanToFocus(best) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 기준일과 가장 가까운 업무 — 진행 중 우선, 없으면 가장 최근에 끝난 업무 */
|
||||||
|
export function resolveTaskNearestMilestoneId(
|
||||||
|
task: TaskWithMilestones,
|
||||||
|
refDate: Date,
|
||||||
|
): string | null {
|
||||||
|
const refMs = startOfDay(refDate).getTime();
|
||||||
|
const spans = collectPeriodSpans(task);
|
||||||
|
if (spans.length === 0) return null;
|
||||||
|
|
||||||
|
let best: { span: PeriodSpan; score: number } | null = null;
|
||||||
|
for (const span of spans) {
|
||||||
|
let score: number;
|
||||||
|
if (refMs >= span.startMs && refMs <= span.endMs) {
|
||||||
|
score = 0;
|
||||||
|
} else if (refMs > span.endMs) {
|
||||||
|
score = refMs - span.endMs;
|
||||||
|
} else {
|
||||||
|
score = span.startMs - refMs + Number.MAX_SAFE_INTEGER / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!best ||
|
||||||
|
score < best.score ||
|
||||||
|
(score === best.score && span.endMs > best.span.endMs) ||
|
||||||
|
(score === best.score && span.endMs === best.span.endMs && span.order < best.span.order)
|
||||||
|
) {
|
||||||
|
best = { span, score };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return best?.span.milestoneId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상세 진입 시 업무 선택 — 주차 → (분기 보기일 때) 분기 → 기준일과 가장 가까운 업무
|
||||||
|
*/
|
||||||
|
export function resolveTaskFocusMilestoneId(
|
||||||
|
task: TaskWithMilestones,
|
||||||
|
options: TaskFocusOptions,
|
||||||
|
): string | null {
|
||||||
|
const weekFocus = resolveTaskWeekFocus(task, options.weekMonday);
|
||||||
|
if (weekFocus) return weekFocus.milestoneId;
|
||||||
|
|
||||||
|
if (!options.weekLensActive && options.quarter) {
|
||||||
|
const quarterFocus = resolveTaskQuarterFocus(task, options.quarter);
|
||||||
|
if (quarterFocus) return quarterFocus.milestoneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refDate = options.weekLensActive ? options.weekMonday : new Date();
|
||||||
|
return resolveTaskNearestMilestoneId(task, refDate);
|
||||||
|
}
|
||||||
@@ -32,11 +32,13 @@ export function taskBelongsToSection(
|
|||||||
return normalizeSection(taskSection) === columnSection;
|
return normalizeSection(taskSection) === columnSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 화면·듀얼모니터 상세에 표시할 부문명 */
|
/** 화면·듀얼모니터 상세·업무관리 — 4분면 보드 표시명 */
|
||||||
export function formatSectionDisplay(section: string | null | undefined): string {
|
export function formatSectionDisplay(section: string | null | undefined): string {
|
||||||
|
if (!section?.trim()) return '—';
|
||||||
|
if (section.trim() === '조직문화') return '조직문화';
|
||||||
const key = normalizeSection(section);
|
const key = normalizeSection(section);
|
||||||
if (key) return key;
|
if (key) return COLUMN_META[key].boardTitle;
|
||||||
return section?.trim() || '—';
|
return section.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canonicalSection(section: string | null | undefined): SectionKey {
|
export function canonicalSection(section: string | null | undefined): SectionKey {
|
||||||
@@ -45,24 +47,27 @@ export function canonicalSection(section: string | null | undefined): SectionKey
|
|||||||
|
|
||||||
export const COLUMN_META: Record<
|
export const COLUMN_META: Record<
|
||||||
SectionKey,
|
SectionKey,
|
||||||
{ titleEn: string; accent: string; displayTitle: string; routineBg: string }
|
{ titleEn: string; accent: string; displayTitle: string; boardTitle: string; routineBg: string }
|
||||||
> = {
|
> = {
|
||||||
인사관리: {
|
인사관리: {
|
||||||
titleEn: 'HRM',
|
titleEn: 'HRM',
|
||||||
accent: '#07412e',
|
accent: '#07412e',
|
||||||
displayTitle: '인사관리',
|
displayTitle: '인사관리',
|
||||||
|
boardTitle: '인사관리',
|
||||||
routineBg: 'linear-gradient(180deg, #dce8e3 0%, #e8f0ec 100%)',
|
routineBg: 'linear-gradient(180deg, #dce8e3 0%, #e8f0ec 100%)',
|
||||||
},
|
},
|
||||||
학습성장: {
|
학습성장: {
|
||||||
titleEn: 'HRD',
|
titleEn: 'HRD',
|
||||||
accent: '#29724f',
|
accent: '#29724f',
|
||||||
displayTitle: '성장지원',
|
displayTitle: '인재육성',
|
||||||
|
boardTitle: '인재육성',
|
||||||
routineBg: 'linear-gradient(180deg, #d8ebe3 0%, #e6f2ec 100%)',
|
routineBg: 'linear-gradient(180deg, #d8ebe3 0%, #e6f2ec 100%)',
|
||||||
},
|
},
|
||||||
운영관리: {
|
운영관리: {
|
||||||
titleEn: 'GA',
|
titleEn: 'GA',
|
||||||
accent: '#36816d',
|
accent: '#36816d',
|
||||||
displayTitle: '총무관리',
|
displayTitle: '총무관리',
|
||||||
|
boardTitle: '총무관리',
|
||||||
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
|
routineBg: 'linear-gradient(180deg, #d4ece4 0%, #e4f3ed 100%)',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
51
frontend/src/lib/stageFormState.ts
Normal file
51
frontend/src/lib/stageFormState.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { decodeRoutineStageDescription } from './routineMilestone';
|
||||||
|
import { parseMilestonePeriods, serializePeriodEntries } from './milestonePeriods';
|
||||||
|
import type { Milestone, MilestoneLink } from '../types';
|
||||||
|
import type { StageFormData } from '../components/detail/stageFormTypes';
|
||||||
|
|
||||||
|
export type { StageFormData };
|
||||||
|
|
||||||
|
export function parseMilestoneLinks(raw: string | null | undefined): MilestoneLink[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as MilestoneLink[];
|
||||||
|
return Array.isArray(parsed) ? parsed.filter((l) => l.url) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStageFormState(
|
||||||
|
milestone: Milestone | undefined,
|
||||||
|
variant: 'project' | 'routine',
|
||||||
|
): StageFormData {
|
||||||
|
const legacyOverview =
|
||||||
|
variant === 'routine' ? decodeRoutineStageDescription(milestone?.description).overview : '';
|
||||||
|
return {
|
||||||
|
title: milestone?.title ?? '',
|
||||||
|
subtitle: milestone?.subtitle?.trim() ?? legacyOverview,
|
||||||
|
periodEntries: parseMilestonePeriods(milestone),
|
||||||
|
progress: milestone?.progress ?? 0,
|
||||||
|
links: parseMilestoneLinks(milestone?.links),
|
||||||
|
pmMemberId: milestone?.pmMemberId ?? milestone?.pmMember?.id ?? '',
|
||||||
|
assigneeMemberIds: milestone?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageFormToApiPayload(data: StageFormData, variant: 'project' | 'routine') {
|
||||||
|
const base = {
|
||||||
|
title: data.title.trim(),
|
||||||
|
periodEntries: serializePeriodEntries(data.periodEntries),
|
||||||
|
progress: data.progress,
|
||||||
|
links: data.links,
|
||||||
|
};
|
||||||
|
if (variant === 'routine') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
subtitle: data.subtitle.trim() || null,
|
||||||
|
pmMemberId: data.pmMemberId || null,
|
||||||
|
assigneeMemberIds: data.assigneeMemberIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TaskFormData } from '../components/common/TaskModal';
|
import type { TaskFormData } from './taskFormState';
|
||||||
import type { RoutineCategory } from './routineCategories';
|
import type { RoutineCategory } from './routineCategories';
|
||||||
import { displayFlagsForTaskType } from './taskType';
|
import { displayFlagsForTaskType } from './taskType';
|
||||||
import { serializeIssueEntries } from './taskIssues';
|
import { serializeIssueEntries } from './taskIssues';
|
||||||
@@ -34,7 +34,10 @@ export function taskFormToApiPayload(data: TaskFormData): Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function projectFormToApiPayload(data: TaskFormData): Record<string, unknown> {
|
export function projectFormToApiPayload(data: TaskFormData): Record<string, unknown> {
|
||||||
return basePayload(data, '실행과제');
|
return {
|
||||||
|
...basePayload(data, '실행과제'),
|
||||||
|
detailDescription: data.detailDescription || null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routineFormToApiPayload(data: TaskFormData): Record<string, unknown> {
|
export function routineFormToApiPayload(data: TaskFormData): Record<string, unknown> {
|
||||||
|
|||||||
72
frontend/src/lib/taskFormState.ts
Normal file
72
frontend/src/lib/taskFormState.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { Task, TaskIssueEntry } from '../types';
|
||||||
|
import { normalizeTaskType } from './taskType';
|
||||||
|
import { parseIssueEntries } from './taskIssues';
|
||||||
|
import { getRoutineCategory } from './routineCategories';
|
||||||
|
|
||||||
|
export interface TaskFormData {
|
||||||
|
title: string;
|
||||||
|
section: string;
|
||||||
|
category: string;
|
||||||
|
tag: string;
|
||||||
|
taskType: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
description: string;
|
||||||
|
detailDescription: string;
|
||||||
|
issueEntries: TaskIssueEntry[];
|
||||||
|
quarter: string;
|
||||||
|
startDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
pmMemberId: string;
|
||||||
|
assigneeMemberIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS = [
|
||||||
|
{ value: 'TODO', label: '대기' },
|
||||||
|
{ value: 'IN_PROGRESS', label: '진행' },
|
||||||
|
{ value: 'REVIEW', label: '보류' },
|
||||||
|
{ value: 'DONE', label: '완료' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const STATUS_LABEL: Record<string, string> = {
|
||||||
|
IN_PROGRESS: '진행',
|
||||||
|
REVIEW: '보류',
|
||||||
|
TODO: '대기',
|
||||||
|
DONE: '완료',
|
||||||
|
CANCELLED: '취소',
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateInput(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTaskFormState(opts: {
|
||||||
|
task?: Task;
|
||||||
|
variant: 'project' | 'routine';
|
||||||
|
defaultSection?: string;
|
||||||
|
defaultCategory?: string;
|
||||||
|
defaultQuarter?: string;
|
||||||
|
}): TaskFormData {
|
||||||
|
const { task, variant, defaultSection = '인사관리', defaultCategory = '채용 운영', defaultQuarter = '2026-Q2' } =
|
||||||
|
opts;
|
||||||
|
const isRoutine = variant === 'routine';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: task?.title ?? '',
|
||||||
|
section: task?.section ?? defaultSection,
|
||||||
|
category: (task ? getRoutineCategory(task) : null) ?? defaultCategory,
|
||||||
|
tag: task?.tag ?? '',
|
||||||
|
taskType: isRoutine ? '기반업무' : task?.taskType ? normalizeTaskType(task.taskType) : '실행과제',
|
||||||
|
status: task?.status ?? 'TODO',
|
||||||
|
progress: task?.progress ?? 0,
|
||||||
|
description: task?.description ?? '',
|
||||||
|
detailDescription: task?.detailDescription ?? '',
|
||||||
|
issueEntries: task ? parseIssueEntries(task) : [],
|
||||||
|
quarter: task?.quarter ?? defaultQuarter,
|
||||||
|
startDate: toDateInput(task?.startDate),
|
||||||
|
dueDate: toDateInput(task?.dueDate),
|
||||||
|
pmMemberId: task?.pmMember?.id ?? task?.pmMemberId ?? '',
|
||||||
|
assigneeMemberIds: task?.assigneeMembers?.map((m) => m.id) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
import type { Task } from '../types';
|
import type { Task } from '../types';
|
||||||
|
import { isSameWeek, parseIsoDate, toIsoDate } from './boardCalendar';
|
||||||
|
|
||||||
export interface TaskIssueEntry {
|
export interface TaskIssueEntry {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
showOnCard: boolean;
|
showOnCard: boolean;
|
||||||
|
/** YYYY-MM-DD — 주차 렌즈에서 해당 주에만 카드 표시 */
|
||||||
|
occurredOn?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newIssueEntry(text = ''): TaskIssueEntry {
|
export interface IssueVisibilityOptions {
|
||||||
|
weekMonday?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newIssueEntry(text = '', occurredOn?: string): TaskIssueEntry {
|
||||||
return {
|
return {
|
||||||
id: `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
id: `issue-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
text,
|
text,
|
||||||
showOnCard: true,
|
showOnCard: true,
|
||||||
|
occurredOn: occurredOn ?? toIsoDate(new Date()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOccurredOn(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
||||||
|
return parseIsoDate(raw.trim()) ? raw.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
||||||
if (!Array.isArray(raw)) return [];
|
if (!Array.isArray(raw)) return [];
|
||||||
const entries: TaskIssueEntry[] = [];
|
const entries: TaskIssueEntry[] = [];
|
||||||
@@ -25,6 +38,7 @@ export function normalizeIssueEntries(raw: unknown): TaskIssueEntry[] {
|
|||||||
id: typeof row.id === 'string' && row.id ? row.id : newIssueEntry().id,
|
id: typeof row.id === 'string' && row.id ? row.id : newIssueEntry().id,
|
||||||
text,
|
text,
|
||||||
showOnCard: row.showOnCard !== false,
|
showOnCard: row.showOnCard !== false,
|
||||||
|
occurredOn: normalizeOccurredOn(row.occurredOn),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
@@ -35,19 +49,37 @@ export function parseIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote'
|
|||||||
if (fromJson.length > 0) return fromJson;
|
if (fromJson.length > 0) return fromJson;
|
||||||
const legacy = task.issueNote?.trim();
|
const legacy = task.issueNote?.trim();
|
||||||
if (!legacy) return [];
|
if (!legacy) return [];
|
||||||
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false }];
|
return [{ id: 'legacy', text: legacy, showOnCard: task.showIssue !== false, occurredOn: null }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVisibleIssueEntries(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): TaskIssueEntry[] {
|
function issueInWeek(entry: TaskIssueEntry, weekMonday: Date): boolean {
|
||||||
return parseIssueEntries(task).filter((entry) => entry.showOnCard && entry.text.trim());
|
if (!entry.occurredOn) return false;
|
||||||
|
const day = parseIsoDate(entry.occurredOn);
|
||||||
|
if (!day) return false;
|
||||||
|
return isSameWeek(day, weekMonday);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasVisibleIssue(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): boolean {
|
export function getVisibleIssueEntries(
|
||||||
return getVisibleIssueEntries(task).length > 0;
|
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
|
||||||
|
options?: IssueVisibilityOptions,
|
||||||
|
): TaskIssueEntry[] {
|
||||||
|
const entries = parseIssueEntries(task).filter((entry) => entry.showOnCard && entry.text.trim());
|
||||||
|
if (!options?.weekMonday) return entries;
|
||||||
|
return entries.filter((entry) => issueInWeek(entry, options.weekMonday!));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPrimaryIssueText(task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>): string | null {
|
export function hasVisibleIssue(
|
||||||
const visible = getVisibleIssueEntries(task);
|
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
|
||||||
|
options?: IssueVisibilityOptions,
|
||||||
|
): boolean {
|
||||||
|
return getVisibleIssueEntries(task, options).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryIssueText(
|
||||||
|
task: Pick<Task, 'issueEntries' | 'issueNote' | 'showIssue'>,
|
||||||
|
options?: IssueVisibilityOptions,
|
||||||
|
): string | null {
|
||||||
|
const visible = getVisibleIssueEntries(task, options);
|
||||||
if (visible.length > 0) return visible[visible.length - 1].text;
|
if (visible.length > 0) return visible[visible.length - 1].text;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -58,6 +90,7 @@ export function serializeIssueEntries(entries: TaskIssueEntry[]): TaskIssueEntry
|
|||||||
id: entry.id,
|
id: entry.id,
|
||||||
text: entry.text.trim(),
|
text: entry.text.trim(),
|
||||||
showOnCard: entry.showOnCard,
|
showOnCard: entry.showOnCard,
|
||||||
|
occurredOn: entry.occurredOn?.trim() || null,
|
||||||
}))
|
}))
|
||||||
.filter((entry) => entry.text.length > 0);
|
.filter((entry) => entry.text.length > 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Task, TaskStatus } from '../types';
|
import type { Task, TaskStatus } from '../types';
|
||||||
import { hasVisibleIssue } from './taskIssues';
|
import { hasVisibleIssue, type IssueVisibilityOptions } from './taskIssues';
|
||||||
|
|
||||||
/** DashboardHeader STAT_ACCENT 와 동일 */
|
/** DashboardHeader STAT_ACCENT 와 동일 */
|
||||||
export const TASK_STAT_COLORS = {
|
export const TASK_STAT_COLORS = {
|
||||||
@@ -20,8 +20,9 @@ export interface DonutDisplay {
|
|||||||
|
|
||||||
export function getProjectTitleStatusClass(
|
export function getProjectTitleStatusClass(
|
||||||
task: Pick<Task, 'status' | 'showIssue' | 'issueNote' | 'issueEntries'>,
|
task: Pick<Task, 'status' | 'showIssue' | 'issueNote' | 'issueEntries'>,
|
||||||
|
options?: IssueVisibilityOptions,
|
||||||
): string {
|
): string {
|
||||||
if (hasVisibleIssue(task)) return 'project-sub-title--issue';
|
if (hasVisibleIssue(task, options)) return 'project-sub-title--issue';
|
||||||
switch (task.status) {
|
switch (task.status) {
|
||||||
case 'IN_PROGRESS':
|
case 'IN_PROGRESS':
|
||||||
return 'project-sub-title--in-progress';
|
return 'project-sub-title--in-progress';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task, TeamMember } from '../types';
|
import type { Milestone, Task, TeamMember } from '../types';
|
||||||
import { isRoutineTask, isProjectTask } from './taskType';
|
import { isRoutineTask, isProjectTask } from './taskType';
|
||||||
|
|
||||||
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
|
/** 셀 컬럼 기본 순서 — 팀원 데이터에 다른 셀이 있으면 뒤에 추가 */
|
||||||
@@ -142,6 +142,20 @@ export function getTaskPeopleHeaderParts(task: Task): {
|
|||||||
return { pmName, assigneeNames };
|
return { pmName, assigneeNames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 상세 헤더 — 단계(마일스톤) PM / 담당 */
|
||||||
|
export function getMilestonePeopleHeaderParts(milestone: Milestone | null | undefined): {
|
||||||
|
pmName: string | null;
|
||||||
|
assigneeNames: string[];
|
||||||
|
} {
|
||||||
|
if (!milestone) return { pmName: null, assigneeNames: [] };
|
||||||
|
const pmId = milestone.pmMember?.id ?? milestone.pmMemberId ?? null;
|
||||||
|
const pmName = milestone.pmMember?.name?.trim() || null;
|
||||||
|
const assigneeNames = (milestone.assigneeMembers ?? [])
|
||||||
|
.filter((m) => m.name?.trim() && m.id !== pmId)
|
||||||
|
.map((m) => m.name!.trim());
|
||||||
|
return { pmName, assigneeNames };
|
||||||
|
}
|
||||||
|
|
||||||
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
export function getHighlightMemberIds(task: Task | null | undefined): string[] {
|
||||||
if (!task) return [];
|
if (!task) return [];
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import App from './App';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import './styles/quarter-board.css';
|
import './styles/quarter-board.css';
|
||||||
import './styles/detail-theme.css';
|
import './styles/detail-theme.css';
|
||||||
|
import './styles/task-manager.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { DonutGauge } from '../components/dashboard/DonutGauge';
|
|||||||
import { TaskManager } from '../components/dashboard/TaskManager';
|
import { TaskManager } from '../components/dashboard/TaskManager';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor';
|
import { sendTaskSelected, openDetailWindow, registerSyncProvider, isDetailWindowOpen, getWindowPlacementHint } from '../lib/dualMonitor';
|
||||||
|
import { resolveTaskFocusMilestoneId } from '../lib/milestoneWeekFocus';
|
||||||
|
import { startOfWeekMonday } from '../lib/boardCalendar';
|
||||||
import { TaskDetailShell } from '../pages/DetailPage';
|
import { TaskDetailShell } from '../pages/DetailPage';
|
||||||
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
|
import { isRoutineTask, isProjectTask, displayFlagsForTaskType } from '../lib/taskType';
|
||||||
import { hasVisibleIssue } from '../lib/taskIssues';
|
import { hasVisibleIssue } from '../lib/taskIssues';
|
||||||
@@ -97,7 +99,20 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const socket = useSocket();
|
const socket = useSocket();
|
||||||
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
|
const {
|
||||||
|
referenceDate,
|
||||||
|
referenceWeekMonday,
|
||||||
|
quarter,
|
||||||
|
weekLensActive,
|
||||||
|
selectWeek,
|
||||||
|
selectQuarter,
|
||||||
|
returnToCurrentQuarter,
|
||||||
|
} = useBoardReferenceDate();
|
||||||
|
|
||||||
|
const issueVisibility = useMemo(
|
||||||
|
() => (weekLensActive ? { weekMonday: referenceWeekMonday } : undefined),
|
||||||
|
[weekLensActive, referenceWeekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
||||||
const { data: teamMembers = [] } = useTeamMembers();
|
const { data: teamMembers = [] } = useTeamMembers();
|
||||||
@@ -288,7 +303,7 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filtered = tasks.filter((t) => {
|
const filtered = tasks.filter((t) => {
|
||||||
if (issueFilterActive) return hasVisibleIssue(t);
|
if (issueFilterActive) return hasVisibleIssue(t, issueVisibility);
|
||||||
return taskMatchesStatusFilters(t, activeFilters);
|
return taskMatchesStatusFilters(t, activeFilters);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,9 +326,9 @@ export default function DashboardPage() {
|
|||||||
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
|
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
|
||||||
).length,
|
).length,
|
||||||
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
|
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
|
||||||
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
|
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t, issueVisibility)).length,
|
||||||
}),
|
}),
|
||||||
[boardProjectTasks],
|
[boardProjectTasks, issueVisibility],
|
||||||
);
|
);
|
||||||
|
|
||||||
const routineTasks = useMemo(
|
const routineTasks = useMemo(
|
||||||
@@ -360,7 +375,7 @@ export default function DashboardPage() {
|
|||||||
setSelectedTaskId(taskId);
|
setSelectedTaskId(taskId);
|
||||||
setDetailStageId(stageId ?? null);
|
setDetailStageId(stageId ?? null);
|
||||||
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
if (teamPanelOpen) setActiveTeamProjectId(taskId);
|
||||||
void sendTaskSelected(taskId, () => setDetailPopupOpen(false)).then((popupOpened) => {
|
void sendTaskSelected(taskId, stageId ?? null, () => setDetailPopupOpen(false)).then((popupOpened) => {
|
||||||
if (popupOpened) {
|
if (popupOpened) {
|
||||||
setDetailPopupOpen(true);
|
setDetailPopupOpen(true);
|
||||||
} else if (!ultraWideLayout) {
|
} else if (!ultraWideLayout) {
|
||||||
@@ -380,6 +395,15 @@ export default function DashboardPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resolveProjectStageId = (task: (typeof filtered)[number]) => {
|
||||||
|
const weekMonday = weekLensActive ? referenceWeekMonday : startOfWeekMonday(new Date());
|
||||||
|
return resolveTaskFocusMilestoneId(task, {
|
||||||
|
weekMonday,
|
||||||
|
weekLensActive,
|
||||||
|
quarter,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
|
const renderDeptSlot = (slotId: typeof BOARD_SLOT_ORDER[number]) => {
|
||||||
const slot = getBoardSlot(slotId);
|
const slot = getBoardSlot(slotId);
|
||||||
const label = slotSectionLabel(slot);
|
const label = slotSectionLabel(slot);
|
||||||
@@ -390,7 +414,8 @@ export default function DashboardPage() {
|
|||||||
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
|
tasks={filtered.filter((t) => taskBelongsToBoardSlot(t, slot))}
|
||||||
orderedIds={columnOrders[label] ?? []}
|
orderedIds={columnOrders[label] ?? []}
|
||||||
quarter={quarter}
|
quarter={quarter}
|
||||||
onSelectTask={(t) => handleSelectTask(t.id)}
|
referenceWeekMonday={weekLensActive ? referenceWeekMonday : undefined}
|
||||||
|
onSelectTask={(t) => handleSelectTask(t.id, resolveProjectStageId(t))}
|
||||||
sectionOptions={sectionOptions}
|
sectionOptions={sectionOptions}
|
||||||
teamMembers={teamMembers}
|
teamMembers={teamMembers}
|
||||||
/>
|
/>
|
||||||
@@ -425,8 +450,11 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
quarter={quarter}
|
quarter={quarter}
|
||||||
referenceDate={referenceDate}
|
referenceWeekMonday={referenceWeekMonday}
|
||||||
onReferenceDateChange={setReferenceDate}
|
weekLensActive={weekLensActive}
|
||||||
|
onSelectWeek={selectWeek}
|
||||||
|
onSelectQuarter={selectQuarter}
|
||||||
|
onReturnToCurrentQuarter={returnToCurrentQuarter}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
issueFilterActive={issueFilterActive}
|
issueFilterActive={issueFilterActive}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { useState, useEffect, useMemo } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
|
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
|
||||||
import { onDualMonitorEvent, getPersistedTaskId } from '../lib/dualMonitor';
|
import { onDualMonitorEvent, getPersistedTaskId, getPersistedStageId } from '../lib/dualMonitor';
|
||||||
import { ContextMenu } from '../components/common/ContextMenu';
|
import { ContextMenu } from '../components/common/ContextMenu';
|
||||||
import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
|
import { FeedbackModal, type FeedbackFormData } from '../components/detail/FeedbackModal';
|
||||||
|
import { OverviewEditModal, type OverviewFormData } from '../components/detail/OverviewEditModal';
|
||||||
import { ResultPreview } from '../components/detail/ResultPreview';
|
import { ResultPreview } from '../components/detail/ResultPreview';
|
||||||
import {
|
import {
|
||||||
StageModal,
|
StageModal,
|
||||||
@@ -16,11 +17,15 @@ import { sortFilesByOrder } from '../lib/fileDisplay';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { formatSectionDisplay } from '../lib/sections';
|
import { formatSectionDisplay } from '../lib/sections';
|
||||||
import { getTaskPeopleHeaderParts } from '../lib/teamStatus';
|
import { getTaskPeopleHeaderParts } from '../lib/teamStatus';
|
||||||
import { isRoutineTask } from '../lib/taskType';
|
import { isRoutineTask, isProjectTask } from '../lib/taskType';
|
||||||
import { RoutineDetailShell } from '../components/detail/RoutineDetailView';
|
import { RoutineDetailShell } from '../components/detail/RoutineDetailView';
|
||||||
import { MilestoneTimeline } from '../components/detail/MilestoneTimeline';
|
import { MilestoneTimeline, type TimelineViewMode } from '../components/detail/MilestoneTimeline';
|
||||||
import { MilestoneContentList } from '../components/detail/MilestoneContentList';
|
import { MilestoneContentList } from '../components/detail/MilestoneContentList';
|
||||||
import { taskTimelineFallback } from '../lib/milestoneTimeline';
|
import {
|
||||||
|
taskTimelineFallback,
|
||||||
|
sortMilestonesForTimeline,
|
||||||
|
} from '../lib/milestoneTimeline';
|
||||||
|
import { resolveTaskNearestMilestoneId } from '../lib/milestoneWeekFocus';
|
||||||
import { fmtMilestonePeriodSummary, serializePeriodEntries } from '../lib/milestonePeriods';
|
import { fmtMilestonePeriodSummary, serializePeriodEntries } from '../lib/milestonePeriods';
|
||||||
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
|
import type { Task, Milestone, FileRecord, TaskDetail } from '../types';
|
||||||
import { useSocket } from '../contexts/SocketContext';
|
import { useSocket } from '../contexts/SocketContext';
|
||||||
@@ -62,13 +67,17 @@ function sortByIsoDesc<T>(items: T[], pick: (item: T) => string) {
|
|||||||
return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime());
|
return [...items].sort((a, b) => new Date(pick(b)).getTime() - new Date(pick(a)).getTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 시작일이 늦은 단계가 상단 (시작일 없으면 종료일 → 생성일 순) */
|
/** 시작일이 빠른 단계가 상단 (시작일 없으면 종료일 → 생성일 순) */
|
||||||
function sortStagesByStartDesc(stages: Milestone[]) {
|
function sortStagesByStartAsc(stages: Milestone[]) {
|
||||||
const pickStart = (m: Milestone) =>
|
return sortMilestonesForTimeline(stages);
|
||||||
m.startDate ?? m.dueDate ?? m.createdAt;
|
}
|
||||||
return [...stages].sort(
|
|
||||||
(a, b) => new Date(pickStart(b)).getTime() - new Date(pickStart(a)).getTime(),
|
function defaultStageId(milestones: Milestone[]): string | null {
|
||||||
);
|
if (milestones.length === 0) return null;
|
||||||
|
const sorted = sortStagesByStartAsc(milestones);
|
||||||
|
const nearest = resolveTaskNearestMilestoneId({ milestones }, new Date());
|
||||||
|
if (nearest && sorted.some((m) => m.id === nearest)) return nearest;
|
||||||
|
return sorted[0]?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedbackAuthorName(detail: TaskDetail) {
|
function feedbackAuthorName(detail: TaskDetail) {
|
||||||
@@ -119,9 +128,18 @@ function PanelLabel({ children, sub }: { children: React.ReactNode; sub?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeftSection({ children }: { children: React.ReactNode }) {
|
function LeftSection({
|
||||||
|
children,
|
||||||
|
onContextMenu,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onContextMenu?: (e: React.MouseEvent) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0">
|
<section
|
||||||
|
className="flex min-h-0 flex-col overflow-hidden border-b border-[#e8edf2] px-4 py-3 last:border-b-0"
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -179,7 +197,7 @@ function DetailHeader({ task }: { task: Task }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="detail-page-header__divider" aria-hidden="true" />
|
<span className="detail-page-header__divider" aria-hidden="true" />
|
||||||
<span>
|
<span>
|
||||||
<span className="detail-page-header__meta-label">부서 </span>
|
<span className="detail-page-header__meta-label">부문 </span>
|
||||||
<span className="detail-page-header__meta-value">{formatSectionDisplay(task.section)}</span>
|
<span className="detail-page-header__meta-value">{formatSectionDisplay(task.section)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="detail-page-header__badge">{status.label}</span>
|
<span className="detail-page-header__badge">{status.label}</span>
|
||||||
@@ -188,7 +206,13 @@ function DetailHeader({ task }: { task: Task }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailView({ task }: { task: TaskWithRelations }) {
|
function DetailView({
|
||||||
|
task,
|
||||||
|
initialStageId,
|
||||||
|
}: {
|
||||||
|
task: TaskWithRelations;
|
||||||
|
initialStageId?: string | null;
|
||||||
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [stageSaving, setStageSaving] = useState(false);
|
const [stageSaving, setStageSaving] = useState(false);
|
||||||
@@ -198,27 +222,65 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; stageId: string } | null>(null);
|
||||||
const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null);
|
const [contentCtx, setContentCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null);
|
const [feedbackCtx, setFeedbackCtx] = useState<{ x: number; y: number; detailId?: string } | null>(null);
|
||||||
|
const [overviewCtx, setOverviewCtx] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [overviewModalOpen, setOverviewModalOpen] = useState(false);
|
||||||
|
const [overviewSaving, setOverviewSaving] = useState(false);
|
||||||
const [overviewExpanded, setOverviewExpanded] = useState(false);
|
const [overviewExpanded, setOverviewExpanded] = useState(false);
|
||||||
|
const [timelineView, setTimelineView] = useState<TimelineViewMode>('focus');
|
||||||
|
|
||||||
const milestones = task.milestones ?? [];
|
const milestones = task.milestones ?? [];
|
||||||
const files = task.files ?? [];
|
const files = task.files ?? [];
|
||||||
const details = task.details ?? [];
|
const details = task.details ?? [];
|
||||||
|
|
||||||
const sortedStages = useMemo(
|
const sortedStages = useMemo(
|
||||||
() => sortStagesByStartDesc(milestones),
|
() => sortStagesByStartAsc(milestones),
|
||||||
[milestones],
|
[milestones],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(sortedStages[0]?.id ?? null);
|
const { data: quarterTasks = [] } = useQuery({
|
||||||
|
queryKey: ['tasks', { quarter: task.quarter }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get<Task[]>('/tasks', { params: { quarter: task.quarter } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: timelineView === 'portfolio',
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const quarterProjectTasks = useMemo(
|
||||||
|
() => quarterTasks.filter((t) => isProjectTask(t.taskType)),
|
||||||
|
[quarterTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioTasks = useMemo(
|
||||||
|
() =>
|
||||||
|
quarterProjectTasks.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
milestones: sortMilestonesForTimeline((t.milestones ?? []) as Milestone[]),
|
||||||
|
})),
|
||||||
|
[quarterProjectTasks],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showGanttPanel = timelineView !== 'focus';
|
||||||
|
const showPortfolioFull = timelineView === 'portfolio';
|
||||||
|
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(() => {
|
||||||
|
if (initialStageId && milestones.some((m) => m.id === initialStageId)) return initialStageId;
|
||||||
|
return defaultStageId(milestones);
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId || !sortedStages.some((s) => s.id === selectedId)) {
|
if (initialStageId && sortedStages.some((s) => s.id === initialStageId)) {
|
||||||
setSelectedId(sortedStages[0]?.id ?? null);
|
setSelectedId(initialStageId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [task.id, sortedStages, selectedId]);
|
setSelectedId(defaultStageId(sortedStages));
|
||||||
|
}, [task.id, initialStageId, sortedStages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOverviewExpanded(false);
|
setOverviewExpanded(false);
|
||||||
|
setTimelineView('focus');
|
||||||
}, [task.id]);
|
}, [task.id]);
|
||||||
|
|
||||||
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
|
const selected = sortedStages.find((m) => m.id === selectedId) ?? sortedStages[0] ?? null;
|
||||||
@@ -248,7 +310,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
|
|
||||||
const deleteMs = useMutation({
|
const deleteMs = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/milestones/item/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
|
onSuccess: () => invalidateTaskCaches(qc, task.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
|
const uploadFiles = async (milestoneId: string, filePayload: StageFileSavePayload['uploads']) => {
|
||||||
@@ -320,7 +382,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
|
alert(`단계는 저장됐지만 ${getApiErrorMessage(err, '파일 처리에 실패했습니다.')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
await invalidateTaskCaches(qc, task.id);
|
||||||
setStageModal(null);
|
setStageModal(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
|
alert(getApiErrorMessage(err, '단계 저장에 실패했습니다.'));
|
||||||
@@ -349,7 +411,7 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
authorName: data.authorName.trim() || null,
|
authorName: data.authorName.trim() || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await qc.invalidateQueries({ queryKey: ['task', task.id] });
|
await invalidateTaskCaches(qc, task.id);
|
||||||
setFeedbackModal(null);
|
setFeedbackModal(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
|
alert(getApiErrorMessage(err, '피드백 저장에 실패했습니다.'));
|
||||||
@@ -360,39 +422,70 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
|
|
||||||
const deleteFeedback = useMutation({
|
const deleteFeedback = useMutation({
|
||||||
mutationFn: (id: string) => apiClient.delete(`/details/item/${id}`),
|
mutationFn: (id: string) => apiClient.delete(`/details/item/${id}`),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['task', task.id] }),
|
onSuccess: () => invalidateTaskCaches(qc, task.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const overviewRaw = task.description?.trim() ?? '';
|
const overviewRaw = task.description?.trim() ?? '';
|
||||||
const overviewDisplay = overviewRaw || '등록된 개요가 없습니다.';
|
const overviewDisplay = overviewRaw || '등록된 개요가 없습니다.';
|
||||||
const canExpandOverview =
|
const detailRaw = task.detailDescription?.trim() ?? '';
|
||||||
overviewRaw.length > 0 && (overviewRaw.includes('\n') || overviewRaw.length > 56);
|
const hasDetailContent = detailRaw.length > 0;
|
||||||
|
|
||||||
|
const handleOverviewSave = async (data: OverviewFormData) => {
|
||||||
|
setOverviewSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/tasks/${task.id}`, {
|
||||||
|
description: data.description.trim() || null,
|
||||||
|
detailDescription: data.detailDescription.trim() || null,
|
||||||
|
});
|
||||||
|
await invalidateTaskCaches(qc, task.id);
|
||||||
|
setOverviewModalOpen(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
alert(getApiErrorMessage(err, '개요 저장에 실패했습니다.'));
|
||||||
|
} finally {
|
||||||
|
setOverviewSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full min-h-0 grid-cols-[1fr_3fr] grid-rows-1">
|
<div
|
||||||
{/* 좌 1/4 — 1:2:2:1 세로 비율 */}
|
className={`grid h-full min-h-0 grid-rows-1 ${showPortfolioFull ? 'detail-page-grid--portfolio' : 'grid-cols-[1fr_3fr]'}`}
|
||||||
|
>
|
||||||
|
{!showPortfolioFull && (
|
||||||
<aside className="detail-aside detail-aside--project grid h-full min-h-0 overflow-hidden">
|
<aside className="detail-aside detail-aside--project grid h-full min-h-0 overflow-hidden">
|
||||||
<LeftSection>
|
<LeftSection
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setOverviewCtx({ x: e.clientX, y: e.clientY });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PanelLabel>개요</PanelLabel>
|
<PanelLabel>개요</PanelLabel>
|
||||||
<div className={`detail-overview${overviewExpanded ? ' is-expanded' : ''}`}>
|
<div className={`detail-overview${hasDetailContent ? ' detail-overview--has-detail' : ''}`}>
|
||||||
<p
|
<p
|
||||||
className={`detail-body-text detail-overview__text${overviewExpanded ? '' : ' is-clamped'}`}
|
className={`detail-body-text detail-overview__text${hasDetailContent ? ' is-clamped' : ''}`}
|
||||||
>
|
>
|
||||||
{overviewDisplay}
|
{overviewDisplay}
|
||||||
</p>
|
</p>
|
||||||
{canExpandOverview && (
|
{hasDetailContent && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="detail-overview__more"
|
className="detail-overview__more"
|
||||||
aria-expanded={overviewExpanded}
|
aria-expanded={overviewExpanded}
|
||||||
aria-label={overviewExpanded ? '개요 접기' : '개요 더 보기'}
|
aria-label={overviewExpanded ? '상세내용 접기' : '상세내용 펼치기'}
|
||||||
title={overviewExpanded ? '접기' : '더 보기'}
|
title={overviewExpanded ? '상세내용 접기' : '상세내용 펼치기'}
|
||||||
onClick={() => setOverviewExpanded((v) => !v)}
|
onClick={() => setOverviewExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<OverviewMoreIcon expanded={overviewExpanded} />
|
<OverviewMoreIcon expanded={overviewExpanded} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{hasDetailContent && (
|
||||||
|
<div
|
||||||
|
className={`detail-overview-detail${overviewExpanded ? ' is-expanded' : ''}`}
|
||||||
|
aria-hidden={!overviewExpanded}
|
||||||
|
>
|
||||||
|
<p className="detail-body-text detail-overview-detail__text">{detailRaw}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</LeftSection>
|
</LeftSection>
|
||||||
|
|
||||||
<LeftSection>
|
<LeftSection>
|
||||||
@@ -498,21 +591,30 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
</div>
|
</div>
|
||||||
</LeftSection>
|
</LeftSection>
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 우 3/4 */}
|
{/* 우 3/4 */}
|
||||||
<div className="flex h-full min-h-0 min-w-0 flex-col">
|
<div
|
||||||
|
className={`detail-main-panel flex h-full min-h-0 min-w-0 flex-col${showGanttPanel ? ' detail-panel--gantt' : ''}${showPortfolioFull ? ' detail-panel--portfolio' : ''}`}
|
||||||
|
>
|
||||||
|
{timelineView === 'focus' && (
|
||||||
<ResultPreview
|
<ResultPreview
|
||||||
files={stageFiles}
|
files={stageFiles}
|
||||||
links={stageLinks}
|
links={stageLinks}
|
||||||
hasSelectedStage={!!selectedId}
|
hasSelectedStage={!!selectedId}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MilestoneTimeline
|
<MilestoneTimeline
|
||||||
|
variant="project"
|
||||||
milestones={sortedStages}
|
milestones={sortedStages}
|
||||||
|
portfolioTasks={portfolioTasks}
|
||||||
fallback={taskTimelineFallback(task)}
|
fallback={taskTimelineFallback(task)}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
preserveRowOrder
|
ownerTaskId={task.id}
|
||||||
|
viewMode={timelineView}
|
||||||
|
onViewModeChange={setTimelineView}
|
||||||
emptyMessage="기간을 설정한 업무 일정만 타임라인에 표시됩니다."
|
emptyMessage="기간을 설정한 업무 일정만 타임라인에 표시됩니다."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -590,6 +692,33 @@ function DetailView({ task }: { task: TaskWithRelations }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{overviewCtx && (
|
||||||
|
<ContextMenu
|
||||||
|
x={overviewCtx.x}
|
||||||
|
y={overviewCtx.y}
|
||||||
|
onClose={() => setOverviewCtx(null)}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: '수정',
|
||||||
|
icon: '✏️',
|
||||||
|
onClick: () => setOverviewModalOpen(true),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overviewModalOpen && (
|
||||||
|
<OverviewEditModal
|
||||||
|
initial={{
|
||||||
|
description: task.description ?? '',
|
||||||
|
detailDescription: task.detailDescription ?? '',
|
||||||
|
}}
|
||||||
|
saving={overviewSaving}
|
||||||
|
onClose={() => setOverviewModalOpen(false)}
|
||||||
|
onSave={handleOverviewSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{feedbackCtx?.detailId && (
|
{feedbackCtx?.detailId && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={feedbackCtx.x}
|
x={feedbackCtx.x}
|
||||||
@@ -670,7 +799,7 @@ export function TaskDetailShell({
|
|||||||
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
|
<div className="detail-page-shell flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
<DetailHeader task={task} />
|
<DetailHeader task={task} />
|
||||||
<div className="h-full min-h-0 flex-1">
|
<div className="h-full min-h-0 flex-1">
|
||||||
<DetailView task={task} />
|
<DetailView task={task} initialStageId={initialStageId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -686,6 +815,7 @@ export default function DetailPage() {
|
|||||||
const [taskId, setTaskId] = useState<string | null>(
|
const [taskId, setTaskId] = useState<string | null>(
|
||||||
() => routeTaskId ?? getPersistedTaskId(),
|
() => routeTaskId ?? getPersistedTaskId(),
|
||||||
);
|
);
|
||||||
|
const [stageId, setStageId] = useState<string | null>(() => getPersistedStageId());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (routeTaskId) setTaskId(routeTaskId);
|
if (routeTaskId) setTaskId(routeTaskId);
|
||||||
@@ -694,8 +824,14 @@ export default function DetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = onDualMonitorEvent(
|
const unsub = onDualMonitorEvent(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (evt.type === 'TASK_SELECTED') setTaskId(evt.taskId);
|
if (evt.type === 'TASK_SELECTED') {
|
||||||
if (evt.type === 'TASK_DESELECTED') setTaskId(null);
|
setTaskId(evt.taskId);
|
||||||
|
setStageId(evt.stageId ?? null);
|
||||||
|
}
|
||||||
|
if (evt.type === 'TASK_DESELECTED') {
|
||||||
|
setTaskId(null);
|
||||||
|
setStageId(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ isPopupView },
|
{ isPopupView },
|
||||||
);
|
);
|
||||||
@@ -706,11 +842,11 @@ export default function DetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="detail-popup-view flex h-screen w-screen overflow-hidden">
|
<div className="detail-popup-view flex h-screen w-screen overflow-hidden">
|
||||||
<aside className="app-right detail-open flex h-full min-h-0 w-full flex-col overflow-hidden">
|
<aside className="app-right detail-open flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
<TaskDetailShell taskId={taskId} />
|
<TaskDetailShell taskId={taskId} initialStageId={stageId} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <TaskDetailShell taskId={taskId} />;
|
return <TaskDetailShell taskId={taskId} initialStageId={stageId} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,20 @@ export default function DummyDashboardPage() {
|
|||||||
const [issueFilterActive, setIssueFilterActive] = useState(false);
|
const [issueFilterActive, setIssueFilterActive] = useState(false);
|
||||||
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
|
const [teamPanelOpen, setTeamPanelOpen] = useState(false);
|
||||||
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
const [columnOrders, setColumnOrders] = useState<Record<string, string[]>>({});
|
||||||
const { referenceDate, setReferenceDate, quarter } = useBoardReferenceDate();
|
const {
|
||||||
|
referenceDate,
|
||||||
|
referenceWeekMonday,
|
||||||
|
quarter,
|
||||||
|
weekLensActive,
|
||||||
|
selectWeek,
|
||||||
|
selectQuarter,
|
||||||
|
returnToCurrentQuarter,
|
||||||
|
} = useBoardReferenceDate();
|
||||||
|
|
||||||
|
const issueVisibility = useMemo(
|
||||||
|
() => (weekLensActive ? { weekMonday: referenceWeekMonday } : undefined),
|
||||||
|
[weekLensActive, referenceWeekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
const { data: tasks = [], isLoading } = useTasks({ quarter });
|
||||||
|
|
||||||
@@ -115,7 +128,7 @@ export default function DummyDashboardPage() {
|
|||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const filtered = tasks.filter((t) => {
|
const filtered = tasks.filter((t) => {
|
||||||
if (issueFilterActive) return hasVisibleIssue(t);
|
if (issueFilterActive) return hasVisibleIssue(t, issueVisibility);
|
||||||
return taskMatchesStatusFilters(t, activeFilters);
|
return taskMatchesStatusFilters(t, activeFilters);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,9 +150,9 @@ export default function DummyDashboardPage() {
|
|||||||
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
|
(t) => t.status === 'REVIEW' || t.status === 'CANCELLED' || t.status === 'TODO',
|
||||||
).length,
|
).length,
|
||||||
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
|
done: boardProjectTasks.filter((t) => t.status === 'DONE').length,
|
||||||
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t)).length,
|
issues: boardProjectTasks.filter((t) => hasVisibleIssue(t, issueVisibility)).length,
|
||||||
}),
|
}),
|
||||||
[boardProjectTasks],
|
[boardProjectTasks, issueVisibility],
|
||||||
);
|
);
|
||||||
|
|
||||||
const routineTasks = useMemo(
|
const routineTasks = useMemo(
|
||||||
@@ -177,8 +190,11 @@ export default function DummyDashboardPage() {
|
|||||||
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
|
<div className="app-main relative flex h-screen min-w-0 flex-col overflow-hidden bg-[#e9eef2]">
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
quarter={quarter}
|
quarter={quarter}
|
||||||
referenceDate={referenceDate}
|
referenceWeekMonday={referenceWeekMonday}
|
||||||
onReferenceDateChange={setReferenceDate}
|
weekLensActive={weekLensActive}
|
||||||
|
onSelectWeek={selectWeek}
|
||||||
|
onSelectQuarter={selectQuarter}
|
||||||
|
onReturnToCurrentQuarter={returnToCurrentQuarter}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
issueFilterActive={issueFilterActive}
|
issueFilterActive={issueFilterActive}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
|
|
||||||
--detail-header-bg: var(--app-header-bg);
|
--detail-header-bg: var(--app-header-bg);
|
||||||
--detail-header-border: var(--app-header-border);
|
--detail-header-border: var(--app-header-border);
|
||||||
--detail-page-bg: #fff;
|
--detail-page-bg: #f2f5f9;
|
||||||
--detail-card-bg: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
|
--detail-aside-bg: #ffffff;
|
||||||
|
--detail-panel-bg: #ffffff;
|
||||||
|
--detail-zone-border: var(--detail-border);
|
||||||
|
--detail-card-bg: #ffffff;
|
||||||
--detail-text-title: var(--detail-ref-title);
|
--detail-text-title: var(--detail-ref-title);
|
||||||
--detail-text-body: var(--detail-ref-title);
|
--detail-text-body: var(--detail-ref-title);
|
||||||
--detail-text-secondary: var(--detail-ref-hub-dark);
|
--detail-text-secondary: var(--detail-ref-hub-dark);
|
||||||
@@ -133,18 +136,31 @@
|
|||||||
|
|
||||||
.detail-page-header__tab {
|
.detail-page-header__tab {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 4px 14px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 3px 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-page-header__tab::before {
|
||||||
|
content: "";
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-page-header__tab:hover:not(:disabled) {
|
.detail-page-header__tab:hover:not(:disabled) {
|
||||||
@@ -156,10 +172,14 @@
|
|||||||
border-color: rgba(255, 255, 255, 0.35);
|
border-color: rgba(255, 255, 255, 0.35);
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: var(--detail-ref-title);
|
color: var(--detail-ref-title);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-page-header__tab.is-active::before {
|
||||||
|
background: var(--detail-ref-hub-dark);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-page-header__tab:disabled {
|
.detail-page-header__tab:disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
@@ -168,6 +188,8 @@
|
|||||||
/* ─── 본문 타이포 (quarter-board project-field · board-project-title) ─── */
|
/* ─── 본문 타이포 (quarter-board project-field · board-project-title) ─── */
|
||||||
.detail-section-label {
|
.detail-section-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid var(--detail-ref-hub);
|
||||||
color: var(--detail-text-title);
|
color: var(--detail-text-title);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -187,7 +209,7 @@
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 10px;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 8px;
|
||||||
border-bottom: 1px solid var(--detail-border);
|
border-bottom: 1px solid var(--detail-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -330,10 +352,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-aside {
|
.detail-aside {
|
||||||
background: var(--detail-card-bg);
|
background: var(--detail-aside-bg);
|
||||||
border-right: 1px solid var(--detail-border);
|
border-right: 1px solid var(--detail-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-aside > section {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: var(--detail-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-main-panel {
|
||||||
|
background: var(--detail-panel-bg);
|
||||||
|
border-left: 1px solid var(--detail-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-page-shell > .grid {
|
||||||
|
background: var(--detail-page-bg);
|
||||||
|
}
|
||||||
|
|
||||||
/* 프로젝트 상세 — 업무 일정 3단계 고정 노출, 업무내용 위로 */
|
/* 프로젝트 상세 — 업무 일정 3단계 고정 노출, 업무내용 위로 */
|
||||||
.detail-aside--project {
|
.detail-aside--project {
|
||||||
--detail-stage-card-h: 80px;
|
--detail-stage-card-h: 80px;
|
||||||
@@ -360,16 +396,49 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-overview {
|
.detail-overview {
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: flex-start;
|
width: 100%;
|
||||||
gap: 4px;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-overview--has-detail .detail-overview__text {
|
||||||
|
padding-right: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-overview__text {
|
.detail-overview__text {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-detail {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
transition:
|
||||||
|
max-height 0.28s ease,
|
||||||
|
opacity 0.22s ease,
|
||||||
|
margin-top 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-detail.is-expanded {
|
||||||
|
max-height: min(42vh, 280px);
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-overview-detail__text {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 2px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
color: var(--detail-text-body, #1e293b);
|
||||||
|
font-size: var(--detail-body-size, 20px);
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-overview__text.is-clamped {
|
.detail-overview__text.is-clamped {
|
||||||
@@ -377,6 +446,7 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-overview.is-expanded .detail-overview__text {
|
.detail-overview.is-expanded .detail-overview__text {
|
||||||
@@ -387,17 +457,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-overview__more {
|
.detail-overview__more {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-top: 2px;
|
margin-top: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: var(--detail-card-bg, #fff);
|
||||||
color: var(--detail-text-secondary);
|
color: var(--detail-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
@@ -454,7 +528,7 @@
|
|||||||
|
|
||||||
.detail-stage-card {
|
.detail-stage-card {
|
||||||
border-color: var(--detail-border);
|
border-color: var(--detail-border);
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-stage-card.is-selected {
|
.detail-stage-card.is-selected {
|
||||||
@@ -495,17 +569,29 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 업무별 타임라인 (간트) — 게이지 4행 고정 ─── */
|
/* ─── 업무별 타임라인 (간트) — 대시보드 타이포 기준 ─── */
|
||||||
.milestone-timeline {
|
.milestone-timeline {
|
||||||
--mt-row-height: 28px;
|
/* 타이포: 최소 18 / 내용 20 / 강조 24 */
|
||||||
--mt-row-gap: 4px;
|
--mt-font-min: 18px;
|
||||||
|
--mt-font-min-weight: 500;
|
||||||
|
--mt-font-content: 20px;
|
||||||
|
--mt-font-content-weight: 500;
|
||||||
|
--mt-font-title: 24px;
|
||||||
|
--mt-font-title-weight: 600;
|
||||||
|
|
||||||
|
--mt-row-height: 38px;
|
||||||
|
--mt-bar-height: 28px;
|
||||||
|
--mt-tick-height: 26px;
|
||||||
|
--mt-row-gap: 6px;
|
||||||
--mt-visible-rows: 4;
|
--mt-visible-rows: 4;
|
||||||
--mt-chart-height: calc(
|
--mt-chart-height: calc(
|
||||||
var(--mt-visible-rows) * var(--mt-row-height)
|
var(--mt-visible-rows) * var(--mt-row-height)
|
||||||
+ (var(--mt-visible-rows) - 1) * var(--mt-row-gap)
|
+ (var(--mt-visible-rows) - 1) * var(--mt-row-gap)
|
||||||
+ 12px
|
+ 14px
|
||||||
|
);
|
||||||
|
--mt-footer-height: calc(
|
||||||
|
38px + 8px + var(--mt-tick-height) + 4px + var(--mt-chart-height) + 26px
|
||||||
);
|
);
|
||||||
--mt-footer-height: calc(26px + 33px + 8px + 22px + 4px + var(--mt-chart-height));
|
|
||||||
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -516,7 +602,7 @@
|
|||||||
max-height: var(--mt-footer-height);
|
max-height: var(--mt-footer-height);
|
||||||
padding: 12px 20px 14px;
|
padding: 12px 20px 14px;
|
||||||
border-top: 1px solid var(--detail-border);
|
border-top: 1px solid var(--detail-border);
|
||||||
background: linear-gradient(168deg, #ffffff 0%, var(--detail-ref-hub-soft) 100%);
|
background: #fff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,16 +616,16 @@
|
|||||||
|
|
||||||
.milestone-timeline__title {
|
.milestone-timeline__title {
|
||||||
color: var(--detail-text-title);
|
color: var(--detail-text-title);
|
||||||
font-size: 24px;
|
font-size: var(--mt-font-title);
|
||||||
font-weight: 600;
|
font-weight: var(--mt-font-title-weight);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milestone-timeline__subtitle {
|
.milestone-timeline__subtitle {
|
||||||
color: var(--detail-accent);
|
color: var(--detail-accent);
|
||||||
font-size: 20px;
|
font-size: var(--mt-font-content);
|
||||||
font-weight: 600;
|
font-weight: var(--mt-font-content-weight);
|
||||||
letter-spacing: -0.1px;
|
letter-spacing: -0.1px;
|
||||||
max-width: 45%;
|
max-width: 45%;
|
||||||
}
|
}
|
||||||
@@ -555,8 +641,8 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.45);
|
background: rgba(255, 255, 255, 0.45);
|
||||||
color: var(--detail-text-muted);
|
color: var(--detail-text-muted);
|
||||||
font-size: 20px;
|
font-size: var(--mt-font-content);
|
||||||
font-weight: 500;
|
font-weight: var(--mt-font-content-weight);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
opacity: 0.72;
|
opacity: 0.72;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -569,12 +655,13 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milestone-timeline__ticks {
|
.milestone-timeline__ticks {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 22px;
|
height: var(--mt-tick-height);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,8 +670,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
color: var(--detail-text-body);
|
color: var(--detail-text-body);
|
||||||
font-size: 13px;
|
font-size: var(--mt-font-min);
|
||||||
font-weight: 600;
|
font-weight: var(--mt-font-min-weight);
|
||||||
letter-spacing: -0.2px;
|
letter-spacing: -0.2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -601,7 +688,7 @@
|
|||||||
|
|
||||||
.milestone-timeline__tick.is-today .milestone-timeline__tick-label {
|
.milestone-timeline__tick.is-today .milestone-timeline__tick-label {
|
||||||
color: var(--detail-ref-hub-dark);
|
color: var(--detail-ref-hub-dark);
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-decoration-color: var(--detail-ref-hub);
|
text-decoration-color: var(--detail-ref-hub);
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
@@ -614,12 +701,12 @@
|
|||||||
height: var(--mt-chart-height);
|
height: var(--mt-chart-height);
|
||||||
min-height: var(--mt-chart-height);
|
min-height: var(--mt-chart-height);
|
||||||
max-height: var(--mt-chart-height);
|
max-height: var(--mt-chart-height);
|
||||||
overflow-y: auto;
|
overflow: hidden auto;
|
||||||
overflow-x: hidden;
|
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(255, 255, 255, 0.55);
|
background: rgba(255, 255, 255, 0.55);
|
||||||
border: 1px solid var(--detail-border);
|
border: 1px solid var(--detail-border);
|
||||||
|
isolation: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.milestone-timeline__grid {
|
.milestone-timeline__grid {
|
||||||
@@ -656,7 +743,7 @@
|
|||||||
.milestone-timeline__bar {
|
.milestone-timeline__bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 22px;
|
height: var(--mt-bar-height);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -672,6 +759,10 @@
|
|||||||
opacity: 0.92;
|
opacity: 0.92;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__bar.is-gantt {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.milestone-timeline__bar.is-expanded {
|
.milestone-timeline__bar.is-expanded {
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -689,8 +780,8 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 18px;
|
font-size: var(--mt-font-min);
|
||||||
font-weight: 500;
|
font-weight: var(--mt-font-min-weight);
|
||||||
letter-spacing: -0.1px;
|
letter-spacing: -0.1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,12 +823,12 @@
|
|||||||
.milestone-timeline__bar-label {
|
.milestone-timeline__bar-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
padding: 0 8px;
|
padding: 0 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-size: 18px;
|
font-size: var(--mt-font-min);
|
||||||
font-weight: 500;
|
font-weight: var(--mt-font-min-weight);
|
||||||
line-height: 22px;
|
line-height: var(--mt-bar-height);
|
||||||
letter-spacing: -0.1px;
|
letter-spacing: -0.1px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -763,3 +854,301 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
text-overflow: clip;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 타임라인 보기 전환 ─── */
|
||||||
|
.milestone-timeline__view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1px solid var(--detail-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__view-toggle button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--detail-text-muted);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__view-toggle button.is-active {
|
||||||
|
background: var(--detail-ref-hub);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel--gantt {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-page-grid--portfolio {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel--portfolio {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel--gantt .milestone-timeline--gantt,
|
||||||
|
.detail-panel--portfolio .milestone-timeline--portfolio {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
--mt-visible-rows: max(4, var(--mt-row-count, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__gantt-layout,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__gantt-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__row-labels,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-labels {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
border-right: 1px solid var(--detail-border);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__row-labels-head,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-labels-head {
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: var(--mt-tick-height);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--detail-text-muted);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
line-height: var(--mt-tick-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__row-labels-body,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-labels-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--mt-row-gap);
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__row-label,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-label {
|
||||||
|
height: var(--mt-row-height);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--detail-text-title);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
text-align: left;
|
||||||
|
line-height: var(--mt-row-height);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__row-label.is-selected,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-label.is-selected {
|
||||||
|
color: var(--detail-ref-hub-dark);
|
||||||
|
background: rgba(74, 144, 217, 0.1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__portfolio-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__portfolio-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__portfolio-chart-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__body--ticks-only {
|
||||||
|
position: relative;
|
||||||
|
height: var(--mt-tick-height);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__viewport-range {
|
||||||
|
padding: 0 8px 2px;
|
||||||
|
color: var(--detail-text-muted);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__viewport-range {
|
||||||
|
padding: 0 8px 2px;
|
||||||
|
color: var(--detail-text-muted);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__portfolio-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 32%) minmax(0, 1fr);
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__group-label {
|
||||||
|
height: var(--mt-row-height);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--detail-text-title);
|
||||||
|
font-size: var(--mt-font-content);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: var(--mt-row-height);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
background: rgba(74, 144, 217, 0.06);
|
||||||
|
border-bottom: 1px solid rgba(74, 144, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-label--child {
|
||||||
|
padding-left: 24px;
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row--group {
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__chart-head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__focus-layout {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__chart--pannable {
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__chart--pannable.is-panning {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__pan-hint,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__portfolio-hint {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 4px 2px;
|
||||||
|
color: var(--detail-text-muted);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: var(--mt-font-min-weight);
|
||||||
|
text-align: right;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__grid--rows {
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-label.is-other-task,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row.is-other-task .milestone-timeline__bar {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__row-labels-body {
|
||||||
|
border-right: 1px solid var(--detail-border);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__body,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline--gantt .milestone-timeline__chart,
|
||||||
|
.milestone-timeline--portfolio .milestone-timeline__chart {
|
||||||
|
flex: 1;
|
||||||
|
min-height: calc(var(--mt-visible-rows) * var(--mt-row-height) + (var(--mt-visible-rows) - 1) * var(--mt-row-gap) + 14px);
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__today-line {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--detail-ref-hub);
|
||||||
|
box-shadow: 0 0 6px rgba(74, 144, 217, 0.35);
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.milestone-timeline__bar-progress {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 3;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: var(--detail-ref-hub-dark);
|
||||||
|
border: 1px solid rgba(74, 144, 217, 0.25);
|
||||||
|
font-size: var(--mt-font-min);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -485,14 +485,22 @@
|
|||||||
transition: stroke 0.2s ease;
|
transition: stroke 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 선택(is-active) 시에만 연동 — 아이콘 직접 hover 없음 */
|
.dummy-board-page .hub-diamond-icon:hover svg path,
|
||||||
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon {
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover) .hub-diamond-icon svg path {
|
||||||
color: var(--ref-hub-routine-active);
|
filter: none !important;
|
||||||
filter: drop-shadow(0 0 6px rgba(37, 99, 171, 0.35));
|
}
|
||||||
transform: scale(1.1);
|
|
||||||
|
/* 선택(is-active) · hover — 색 진하게만, 그림자 없음 */
|
||||||
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon,
|
||||||
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover) .hub-diamond-icon {
|
||||||
|
filter: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon {
|
||||||
|
color: var(--ref-hub-routine-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* quarter-board 녹색 path hover 덮어쓰기 */
|
|
||||||
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon svg path {
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item.is-active) .hub-diamond-icon svg path {
|
||||||
stroke: var(--ref-hub-routine-active) !important;
|
stroke: var(--ref-hub-routine-active) !important;
|
||||||
filter: none !important;
|
filter: none !important;
|
||||||
@@ -500,8 +508,6 @@
|
|||||||
|
|
||||||
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon {
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon {
|
||||||
color: var(--ref-hub-routine-hover);
|
color: var(--ref-hub-routine-hover);
|
||||||
filter: none;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon svg path {
|
.dummy-board-page .hub-diamond-inner:has(.hub-routine-item:hover:not(.is-active)) .hub-diamond-icon svg path {
|
||||||
@@ -532,33 +538,33 @@
|
|||||||
.dummy-board-page .hub-routine-item {
|
.dummy-board-page .hub-routine-item {
|
||||||
color: var(--ref-hub-routine);
|
color: var(--ref-hub-routine);
|
||||||
padding: 2px 2px;
|
padding: 2px 2px;
|
||||||
transition: color 0.2s ease, text-shadow 0.2s ease;
|
font-weight: 500;
|
||||||
text-shadow: none;
|
transition: color 0.2s ease, font-weight 0.2s ease;
|
||||||
|
text-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page .hub-routine-item::before {
|
.dummy-board-page .hub-routine-item::before {
|
||||||
background: currentColor;
|
background: currentColor;
|
||||||
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page .hub-routine-item:hover {
|
.dummy-board-page .hub-routine-item:hover {
|
||||||
color: var(--ref-hub-routine-hover);
|
color: var(--ref-hub-routine-hover);
|
||||||
text-shadow: none;
|
font-weight: 700;
|
||||||
|
text-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page .hub-routine-item.is-active {
|
.dummy-board-page .hub-routine-item.is-active {
|
||||||
color: var(--ref-hub-routine-active);
|
color: var(--ref-hub-routine-active);
|
||||||
text-shadow: 0 0 6px rgba(37, 99, 171, 0.35);
|
font-weight: 700;
|
||||||
}
|
text-shadow: none !important;
|
||||||
|
|
||||||
.dummy-board-page .hub-routine-item:hover::before {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dummy-board-page .hub-routine-item:hover::before,
|
||||||
.dummy-board-page .hub-routine-item.is-active::before {
|
.dummy-board-page .hub-routine-item.is-active::before {
|
||||||
transform: scale(1.1);
|
box-shadow: none !important;
|
||||||
box-shadow: 0 0 5px rgba(37, 99, 171, 0.4);
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page .hub-box--focus {
|
.dummy-board-page .hub-box--focus {
|
||||||
@@ -1008,6 +1014,7 @@
|
|||||||
--dept-donut-size: 96px;
|
--dept-donut-size: 96px;
|
||||||
--project-field-line-gap: 5px;
|
--project-field-line-gap: 5px;
|
||||||
--project-field-edge-pad: 10px;
|
--project-field-edge-pad: 10px;
|
||||||
|
--project-week-row-font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .dept-card {
|
.dummy-board-page--2slots .dept-card {
|
||||||
@@ -1020,12 +1027,12 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: calc(var(--dept-icon-size-2slots) - var(--dept-icon-protrude) + 6px);
|
min-height: calc(var(--dept-icon-size-2slots) - var(--dept-icon-protrude) + 6px);
|
||||||
padding: calc(var(--dept-icon-protrude) + 2px) var(--dept-slot-pad-x);
|
padding: 14px var(--dept-slot-pad-x);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .dept-head .dept-icon {
|
.dummy-board-page--2slots .dept-head .dept-icon {
|
||||||
left: var(--dept-slot-pad-x);
|
left: var(--dept-slot-pad-x);
|
||||||
top: calc(-1 * var(--dept-icon-protrude));
|
top: calc(-0.9 * var(--dept-icon-protrude));
|
||||||
width: var(--dept-icon-size-2slots);
|
width: var(--dept-icon-size-2slots);
|
||||||
height: var(--dept-icon-size-2slots);
|
height: var(--dept-icon-size-2slots);
|
||||||
}
|
}
|
||||||
@@ -1050,7 +1057,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: var(--dept-head-list-gap) var(--dept-slot-pad-x) var(--dept-list-inner-bottom);
|
padding: 4px var(--dept-slot-pad-x) var(--dept-list-inner-bottom);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 0 0 var(--dept-card-inner-radius) var(--dept-card-inner-radius);
|
border-radius: 0 0 var(--dept-card-inner-radius) var(--dept-card-inner-radius);
|
||||||
background: var(--ref-soft, #f4f6f9);
|
background: var(--ref-soft, #f4f6f9);
|
||||||
@@ -1125,7 +1132,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,24 +1185,19 @@
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .project-field--issue-reserved {
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dummy-board-page--2slots .project-sub-title,
|
.dummy-board-page--2slots .project-sub-title,
|
||||||
.dummy-board-page--2slots .project-field {
|
.dummy-board-page--2slots .project-field {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 수행 기간 · 주요 내용 — 라벨 글씨만 숨김 (값은 표시) */
|
/* 수행 기간 · 개요 — 라벨 글씨만 숨김. 주차 보기(--week-*)는 라벨 표시 */
|
||||||
.dummy-board-page--2slots .project-field:not(.project-field--issue) {
|
.dummy-board-page--2slots .project-field:not(.project-field--issue):not(.project-field--week-focus):not(.project-field--week-period):not(.project-field--week-empty) {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding-left: var(--project-title-indent);
|
padding-left: var(--project-title-indent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .project-field:not(.project-field--issue) .project-field-label {
|
.dummy-board-page--2slots .project-field:not(.project-field--issue):not(.project-field--week-focus):not(.project-field--week-period):not(.project-field--week-empty) .project-field-label {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1225,7 +1227,32 @@
|
|||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .project-field .project-field-value {
|
.dummy-board-page--2slots .project-field--issue-reserved {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--overview:not(.project-field--overview-expanded) .project-field-value {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--overview-expanded .project-field-value {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
white-space: normal;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
line-height: 1.28;
|
||||||
|
word-break: break-word;
|
||||||
|
min-height: calc(2 * 1.28em + var(--project-field-line-gap));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field:not(.project-field--overview) .project-field-value:not(.project-field-value--issue) {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1241,6 +1268,45 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--week-focus,
|
||||||
|
.dummy-board-page--2slots .project-field--week-period {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
gap: 2px 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding-left: var(--project-title-indent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--week-focus .project-field-label,
|
||||||
|
.dummy-board-page--2slots .project-field--week-period .project-field-label,
|
||||||
|
.dummy-board-page--2slots .project-field--week-focus .project-field-value,
|
||||||
|
.dummy-board-page--2slots .project-field--week-period .project-field-value {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--project-week-row-font-size);
|
||||||
|
line-height: 1.28;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--week-focus .project-field-label,
|
||||||
|
.dummy-board-page--2slots .project-field--week-period .project-field-label {
|
||||||
|
display: block !important;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--week-focus .project-field-value,
|
||||||
|
.dummy-board-page--2slots .project-field--week-period .project-field-value {
|
||||||
|
color: #5a6b62;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dummy-board-page--2slots .project-field--week-empty .project-field-value {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.dummy-board-page--2slots .project-field--issue {
|
.dummy-board-page--2slots .project-field--issue {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px 1fr;
|
grid-template-columns: 18px 1fr;
|
||||||
|
|||||||
@@ -526,15 +526,15 @@
|
|||||||
--hub-band-h: min(calc(var(--hub-row-h) * 0.473), 171px);
|
--hub-band-h: min(calc(var(--hub-row-h) * 0.473), 171px);
|
||||||
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.567), 205px);
|
--hub-slogan-band-h: min(calc(var(--hub-row-h) * 0.567), 205px);
|
||||||
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
|
/* 일정: 박스 top → 제목 (플래너·헤더 띠에서 역산) */
|
||||||
--hub-title-top: 20px;
|
--hub-title-top: 12px;
|
||||||
/* 슬로건: 포스트잇 상단 = 부서 카드 헤더 상단 */
|
/* 슬로건: 포스트잇 상단 = 부서 카드 헤더 상단 */
|
||||||
--hub-slogan-pad-top: 0;
|
--hub-slogan-pad-top: 0;
|
||||||
--hub-slogan-postit-pad-top: 10px;
|
--hub-slogan-postit-pad-top: 10px;
|
||||||
--hub-slogan-postit-pad-bottom: 10px;
|
--hub-slogan-postit-pad-bottom: 10px;
|
||||||
/* 일정: 헤더 띠 + 플래너 (플래너 padding-top도 title-top에서 역산) */
|
/* 일정: 헤더 띠 + 플래너 (플래너 padding-top도 title-top에서 역산) */
|
||||||
--hub-head-offset-top: -2px;
|
--hub-head-offset-top: -2px;
|
||||||
--hub-head-pad-top: 8px;
|
--hub-head-pad-top: 5px;
|
||||||
--hub-head-pad-bottom: 7px;
|
--hub-head-pad-bottom: 6px;
|
||||||
--hub-card-pad-x: 12px;
|
--hub-card-pad-x: 12px;
|
||||||
--hub-card-pad-bottom: 10px;
|
--hub-card-pad-bottom: 10px;
|
||||||
--hub-postit-pad-x: 14px;
|
--hub-postit-pad-x: 14px;
|
||||||
@@ -605,24 +605,9 @@
|
|||||||
.hub-routine-focus-body {
|
.hub-routine-focus-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 4px;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
width: 100%;
|
||||||
|
|
||||||
.hub-routine-focus-nav {
|
|
||||||
flex: 0 0 20px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--hub-diamond-border, #2f8a66);
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: pointer;
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-routine-focus-nav:disabled {
|
|
||||||
opacity: 0.15;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-focus-task-list {
|
.hub-focus-task-list {
|
||||||
@@ -1075,8 +1060,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hub-box--focus .hub-schedule-planner {
|
.hub-box--focus .hub-schedule-planner {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
height: 100%;
|
height: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -1086,20 +1071,139 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hub-box--focus .hub-schedule-viewport {
|
.hub-box--focus .hub-schedule-viewport {
|
||||||
flex: 1 1 auto;
|
flex: 0 0 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-box--focus .hub-schedule-viewport {
|
.hub-box--focus .hub-schedule-wheel-wrap {
|
||||||
flex: 1 1 auto;
|
width: 100%;
|
||||||
min-height: 0;
|
flex: 0 0 auto;
|
||||||
|
--hub-schedule-row-gap: 8px;
|
||||||
|
--hub-schedule-row-h: 38px;
|
||||||
|
--hub-schedule-row-step: calc(var(--hub-schedule-row-h) + var(--hub-schedule-row-gap));
|
||||||
|
--hub-schedule-view-h: calc(var(--hub-schedule-row-step) * 3 - var(--hub-schedule-row-gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-box--focus .hub-schedule-list {
|
.hub-schedule-wheel-viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--hub-schedule-view-h);
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 16px 0 32px;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-viewport.is-scrollable {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-viewport.is-scrollable.is-dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-viewport.is-scrollable.is-dragging .hub-schedule-wheel-row {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-viewport--empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 56px;
|
||||||
|
height: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-viewport::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 32px;
|
||||||
|
right: 16px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: var(--hub-schedule-row-h);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(90, 107, 98, 0.06);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-track {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--hub-schedule-row-gap, 8px);
|
||||||
|
transition: transform 0.38s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-track.is-dragging {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-row {
|
||||||
|
flex: 0 0 var(--hub-schedule-row-h);
|
||||||
|
height: var(--hub-schedule-row-h);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px dashed #e8e0d4;
|
||||||
|
transition: opacity 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hub-focus-task-item · board-project-desc 와 동일 본문 */
|
||||||
|
.hub-schedule-wheel-date,
|
||||||
|
.hub-schedule-wheel-text {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #5a6b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-date {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: auto 2em;
|
||||||
|
column-gap: 0.15em;
|
||||||
|
align-items: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-text {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-width: 0;
|
||||||
justify-content: flex-start;
|
text-align: left;
|
||||||
padding: 0;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-row.is-past .hub-schedule-wheel-date,
|
||||||
|
.hub-schedule-wheel-row.is-past .hub-schedule-wheel-text {
|
||||||
|
color: #8a9690;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-wheel-row.is-today .hub-schedule-wheel-date,
|
||||||
|
.hub-schedule-wheel-row.is-today .hub-schedule-wheel-text {
|
||||||
|
color: #5a6b62;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-date-month {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hub-schedule-date-day {
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-schedule-planner::before {
|
.hub-schedule-planner::before {
|
||||||
@@ -1115,7 +1219,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
padding:
|
padding:
|
||||||
calc(var(--hub-title-top) - var(--hub-head-pad-top) - var(--hub-head-offset-top))
|
calc(var(--hub-title-top) - var(--hub-head-pad-top) - var(--hub-head-offset-top))
|
||||||
var(--hub-card-pad-x)
|
var(--hub-card-pad-x)
|
||||||
@@ -1545,119 +1649,10 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── 분기 일정 캐러셀 (3건 표시 · 오늘 기준 · 좌우 탐색) ─── */
|
/* (legacy hub-schedule-carousel rules removed — use hub-schedule-wheel-* in media block) */
|
||||||
.hub-schedule-viewport {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-viewport .hub-schedule-list {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 6px calc(var(--hub-body-inset) - var(--hub-card-pad-x)) 4px
|
|
||||||
calc(var(--hub-body-inset) - var(--hub-card-pad-x));
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-viewport--empty {
|
|
||||||
min-height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-empty {
|
.hub-schedule-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hub-schedule-item--past .hub-schedule-date,
|
|
||||||
.hub-schedule-item--past .board-project-desc {
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-item--today .hub-schedule-date,
|
|
||||||
.hub-schedule-item--today .hub-schedule-date-month,
|
|
||||||
.hub-schedule-item--today .hub-schedule-date-day,
|
|
||||||
.hub-schedule-item--today .board-project-desc {
|
|
||||||
color: #5a6b62;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-date {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav {
|
|
||||||
position: absolute;
|
|
||||||
top: 2px;
|
|
||||||
bottom: 2px;
|
|
||||||
z-index: 3;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
color: #5a5349;
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 400;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0.55;
|
|
||||||
pointer-events: auto;
|
|
||||||
transition: opacity 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav--prev {
|
|
||||||
left: -6px;
|
|
||||||
padding-right: 2px;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
rgba(242, 236, 227, 0.95) 0%,
|
|
||||||
rgba(242, 236, 227, 0.55) 60%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav--next {
|
|
||||||
right: -6px;
|
|
||||||
padding-left: 2px;
|
|
||||||
background: linear-gradient(
|
|
||||||
270deg,
|
|
||||||
rgba(242, 236, 227, 0.95) 0%,
|
|
||||||
rgba(242, 236, 227, 0.55) 60%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav:hover:not(:disabled) {
|
|
||||||
opacity: 0.85;
|
|
||||||
color: #3d3832;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav-icon {
|
|
||||||
display: block;
|
|
||||||
font-size: 28px;
|
|
||||||
line-height: 0.75;
|
|
||||||
transform: scaleY(1.55);
|
|
||||||
transform-origin: center center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hub-schedule-nav:disabled {
|
|
||||||
opacity: 0.28;
|
|
||||||
pointer-events: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
.routine-detail {
|
.routine-detail {
|
||||||
--rd-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
|
--rd-header-bg: linear-gradient(180deg, #37a184 0%, #29724f 20%, #07412e 100%);
|
||||||
--rd-emerald-deep: #1e3a5f;
|
--rd-emerald-deep: #1e3a5f;
|
||||||
--rd-bg-page: #fff;
|
--rd-bg-page: #f2f5f9;
|
||||||
--rd-bg-card: #ffffff;
|
--rd-bg-card: #ffffff;
|
||||||
--rd-bg-card-hover: #f8fafc;
|
--rd-bg-card-hover: #f8fafc;
|
||||||
--rd-bg-stage-selected: #f5faff;
|
--rd-bg-stage-selected: #f5faff;
|
||||||
@@ -358,6 +358,19 @@
|
|||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.routine-detail .grid {
|
||||||
|
background: var(--rd-bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
.routine-detail__panel .milestone-timeline {
|
.routine-detail__panel .milestone-timeline {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.routine-detail__panel .milestone-timeline--gantt,
|
||||||
|
.routine-detail__panel .milestone-timeline--portfolio,
|
||||||
|
.detail-panel--gantt .milestone-timeline--gantt,
|
||||||
|
.detail-panel--portfolio .milestone-timeline--portfolio {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
|||||||
770
frontend/src/styles/task-manager.css
Normal file
770
frontend/src/styles/task-manager.css
Normal file
@@ -0,0 +1,770 @@
|
|||||||
|
/* ── 공통 업무 폼 (모달 · 업무관리) ── */
|
||||||
|
|
||||||
|
.task-form-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-row-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-progress-val {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-input:focus {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-input--title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 72px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-textarea--tall {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-range {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: #29724f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-people {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #d4ebe3;
|
||||||
|
background: rgba(232, 245, 240, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-assignees {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-assignee-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-assignee-chip.is-checked {
|
||||||
|
background: #29724f;
|
||||||
|
border-color: #29724f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px dashed #dbe3ea;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issues {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue-date {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue-date-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue-date-input {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-issue-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-link-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2563ab;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-link-btn:hover {
|
||||||
|
background: rgba(74, 144, 217, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-link-btn--danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-link-btn--danger:hover {
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--ghost {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--ghost:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--primary {
|
||||||
|
background: #29724f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--primary:hover {
|
||||||
|
background: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--primary.is-routine {
|
||||||
|
background: #0d4a38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--danger {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-form-btn--danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── TaskModal ── */
|
||||||
|
|
||||||
|
.task-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(7, 33, 24, 0.55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-shell {
|
||||||
|
width: min(540px, 94vw);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 24px 64px rgba(7, 33, 24, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: linear-gradient(120deg, #0d4a38 0%, #29724f 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-close:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 업무관리 ── */
|
||||||
|
|
||||||
|
.task-manager-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(7, 33, 24, 0.58);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(1180px, 100%);
|
||||||
|
height: min(calc(100dvh - 40px), 920px);
|
||||||
|
max-height: calc(100dvh - 40px);
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f4f7f5;
|
||||||
|
box-shadow: 0 28px 80px rgba(7, 33, 24, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-header {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 22px;
|
||||||
|
background: linear-gradient(120deg, #0d4a38 0%, #29724f 55%, #37a184 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-header__quarter {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-close {
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid #dbe3ea;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-tab {
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-tab.is-active {
|
||||||
|
background: #29724f;
|
||||||
|
border-color: #29724f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-tab.is-active.is-routine {
|
||||||
|
background: #0d4a38;
|
||||||
|
border-color: #0d4a38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-chip {
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
background: #fff;
|
||||||
|
color: #64748b;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-chip.is-active {
|
||||||
|
background: rgba(41, 114, 79, 0.12);
|
||||||
|
border-color: #29724f;
|
||||||
|
color: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-toolbar__spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-search {
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 260px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-search:focus {
|
||||||
|
border-color: #4a90d9;
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 144, 217, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-add {
|
||||||
|
border: none;
|
||||||
|
background: #29724f;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-add:hover {
|
||||||
|
background: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 14px 18px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-empty {
|
||||||
|
margin: auto;
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row {
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #dbe3ea;
|
||||||
|
background: #fff;
|
||||||
|
overflow: visible;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row.is-expanded {
|
||||||
|
border-color: #7eb3e8;
|
||||||
|
box-shadow: 0 4px 16px rgba(41, 114, 79, 0.08);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row--hrm {
|
||||||
|
box-shadow: inset 4px 0 0 #29724f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row--hrd {
|
||||||
|
box-shadow: inset 4px 0 0 #37a184;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row--ex {
|
||||||
|
box-shadow: inset 4px 0 0 #4a9480;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row--ga {
|
||||||
|
box-shadow: inset 4px 0 0 #0d4a38;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row--routine {
|
||||||
|
box-shadow: inset 4px 0 0 #5b2d8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row__summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row__summary:hover {
|
||||||
|
background: rgba(41, 114, 79, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row__chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
color: #94a3b8;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row.is-expanded .task-manager-row__chevron {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row__title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--section {
|
||||||
|
background: rgba(41, 114, 79, 0.1);
|
||||||
|
color: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--category {
|
||||||
|
background: rgba(91, 45, 138, 0.1);
|
||||||
|
color: #5b2d8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--progress {
|
||||||
|
background: rgba(74, 144, 217, 0.12);
|
||||||
|
color: #2563ab;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--status {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--status.is-done {
|
||||||
|
background: rgba(41, 114, 79, 0.12);
|
||||||
|
color: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--status.is-active {
|
||||||
|
background: rgba(74, 144, 217, 0.12);
|
||||||
|
color: #2563ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-panel {
|
||||||
|
padding: 12px 16px 16px;
|
||||||
|
border-top: 1px solid #eef2f6;
|
||||||
|
animation: task-manager-panel-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-row.is-expanded .task-manager-panel {
|
||||||
|
max-height: min(78vh, 860px);
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed #dbe3ea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-section__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-section__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1f5a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: #fafbfc;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow.is-expanded {
|
||||||
|
border-color: #b8d4c8;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow__head:hover {
|
||||||
|
background: rgba(41, 114, 79, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow__title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subrow__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subpanel {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
border-top: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-subpanel--new {
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border: 1px dashed #b8d4c8;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fbf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes task-manager-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #eef2f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-top: 1px solid #dbe3ea;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-badge--status.is-legacy {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-manager-footer strong {
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export interface Task {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
detailDescription?: string | null;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
priority: Priority;
|
priority: Priority;
|
||||||
quarter: string;
|
quarter: string;
|
||||||
@@ -62,6 +63,7 @@ export interface Task {
|
|||||||
creator?: Pick<User, 'id' | 'name'>;
|
creator?: Pick<User, 'id' | 'name'>;
|
||||||
pmMember?: TeamMemberBrief | null;
|
pmMember?: TeamMemberBrief | null;
|
||||||
assigneeMembers?: TeamMemberBrief[];
|
assigneeMembers?: TeamMemberBrief[];
|
||||||
|
milestones?: Milestone[];
|
||||||
_count?: { files: number; details: number };
|
_count?: { files: number; details: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,35 @@ $giteaHost = '172.16.10.175'
|
|||||||
$giteaPort = 222
|
$giteaPort = 222
|
||||||
$giteaRepo = 'RyuWonJun/eene_dashboard.git'
|
$giteaRepo = 'RyuWonJun/eene_dashboard.git'
|
||||||
$sshConfig = Join-Path $env:USERPROFILE '.ssh\config'
|
$sshConfig = Join-Path $env:USERPROFILE '.ssh\config'
|
||||||
$test = ssh -F $sshConfig -i $keyPath -T "git@${giteaHost}" -p $giteaPort -o IdentitiesOnly=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new 2>&1 | Out-String
|
|
||||||
Write-Host $test.TrimEnd()
|
$sshArgs = @(
|
||||||
|
'-i', $keyPath,
|
||||||
|
'-T', "git@${giteaHost}",
|
||||||
|
'-p', $giteaPort,
|
||||||
|
'-o', 'IdentitiesOnly=yes',
|
||||||
|
'-o', 'ConnectTimeout=10',
|
||||||
|
'-o', 'StrictHostKeyChecking=accept-new'
|
||||||
|
)
|
||||||
|
if (Test-Path $sshConfig) {
|
||||||
|
$sshArgs = @('-F', $sshConfig) + $sshArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
$prevEap = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
$sshLines = [System.Collections.Generic.List[string]]::new()
|
||||||
|
try {
|
||||||
|
& ssh @sshArgs 2>&1 | ForEach-Object { [void]$sshLines.Add("$($_)".Trim()) }
|
||||||
|
} finally {
|
||||||
|
$ErrorActionPreference = $prevEap
|
||||||
|
}
|
||||||
|
$test = ($sshLines | Where-Object { $_ }) -join "`n"
|
||||||
|
if (-not $test) { $test = '' }
|
||||||
|
|
||||||
|
if ($test) {
|
||||||
|
Write-Host $test
|
||||||
|
} else {
|
||||||
|
Write-Host ' (no SSH message — checking exit code)' -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
if ($test -match 'Permission denied') {
|
if ($test -match 'Permission denied') {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host '[ERROR] Gitea rejected this key.' -ForegroundColor Red
|
Write-Host '[ERROR] Gitea rejected this key.' -ForegroundColor Red
|
||||||
@@ -50,11 +77,19 @@ if ($test -match 'Permission denied') {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
if ($test -notmatch 'successfully authenticated') {
|
if ($test -notmatch 'successfully authenticated') {
|
||||||
|
if ($LASTEXITCODE -eq 0 -and -not $test) {
|
||||||
|
Write-Host ' SSH OK (exit 0)' -ForegroundColor Green
|
||||||
|
} else {
|
||||||
Write-Host ''
|
Write-Host ''
|
||||||
Write-Host '[ERROR] Unexpected SSH response. Check port and deploy key.' -ForegroundColor Red
|
Write-Host '[ERROR] Unexpected SSH response. Check port and deploy key.' -ForegroundColor Red
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host " ssh exit code: $LASTEXITCODE" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
exit 1
|
exit 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host ' SSH OK' -ForegroundColor Green
|
||||||
}
|
}
|
||||||
Write-Host ' SSH OK' -ForegroundColor Green
|
|
||||||
|
|
||||||
Set-Location $projectRoot
|
Set-Location $projectRoot
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user