Add program type classification

This commit is contained in:
2026-06-24 09:46:02 +09:00
parent 5001477656
commit 25e78ab2f6

View File

@@ -176,6 +176,25 @@ const contentStorageKey = 'program-flow-content';
const programStateStorageKey = 'program-flow-gates'; const programStateStorageKey = 'program-flow-gates';
const serverStateEndpoint = '/api/state'; const serverStateEndpoint = '/api/state';
const disabledFlowStep = '__disabled__'; const disabledFlowStep = '__disabled__';
const programTypeOptions = {
internal: {
label: '사내프로그램',
shortLabel: '사내',
rowClass: 'border-emerald-100 bg-emerald-50/55',
badgeClass: 'bg-emerald-100 text-emerald-700 ring-emerald-200',
dotClass: 'bg-emerald-500'
},
commercial: {
label: '상용프로그램',
shortLabel: '상용',
rowClass: 'border-violet-100 bg-violet-50/55',
badgeClass: 'bg-violet-100 text-violet-700 ring-violet-200',
dotClass: 'bg-violet-500'
}
};
const getProgramType = (type) => (type === 'commercial' ? 'commercial' : 'internal');
const getProgramTypeMeta = (type) => programTypeOptions[getProgramType(type)];
const defaultContent = { const defaultContent = {
cheonjiin: { cheonjiin: {
@@ -184,6 +203,7 @@ const defaultContent = {
steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })), steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })),
deliverables: cheonjiinDeliverables, deliverables: cheonjiinDeliverables,
format: 'glb', format: 'glb',
programType: 'internal',
predecessors: [], predecessors: [],
successors: [] successors: []
}, },
@@ -193,6 +213,7 @@ const defaultContent = {
steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })), steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })),
format: '', format: '',
deliverables: ['기본설계 모델'], deliverables: ['기본설계 모델'],
programType: 'internal',
predecessors: [], predecessors: [],
successors: [], successors: [],
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계' linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
@@ -206,6 +227,7 @@ function normalizeStoredContent(parsed) {
cheonjiin: { cheonjiin: {
...defaultContent.cheonjiin, ...defaultContent.cheonjiin,
...(parsed.cheonjiin ?? {}), ...(parsed.cheonjiin ?? {}),
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
predecessors: parsed.cheonjiin?.predecessors ?? [], predecessors: parsed.cheonjiin?.predecessors ?? [],
successors: parsed.cheonjiin?.successors ?? [], successors: parsed.cheonjiin?.successors ?? [],
format: format:
@@ -216,6 +238,7 @@ function normalizeStoredContent(parsed) {
wayPrimal: { wayPrimal: {
...defaultContent.wayPrimal, ...defaultContent.wayPrimal,
...(parsed.wayPrimal ?? {}), ...(parsed.wayPrimal ?? {}),
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
predecessors: parsed.wayPrimal?.predecessors ?? [], predecessors: parsed.wayPrimal?.predecessors ?? [],
successors: parsed.wayPrimal?.successors ?? [], successors: parsed.wayPrimal?.successors ?? [],
format: format:
@@ -225,6 +248,7 @@ function normalizeStoredContent(parsed) {
}, },
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({ extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
...program, ...program,
programType: getProgramType(program.programType),
predecessors: program.predecessors ?? [], predecessors: program.predecessors ?? [],
successors: program.successors ?? [], successors: program.successors ?? [],
linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계` linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`
@@ -508,6 +532,8 @@ function FlowRow({
onDeliverableChange, onDeliverableChange,
onLabelChange, onLabelChange,
onDescriptionChange, onDescriptionChange,
programType,
onProgramTypeChange,
onAddStep, onAddStep,
onRemoveStep, onRemoveStep,
onMoveStep, onMoveStep,
@@ -523,6 +549,8 @@ function FlowRow({
return 'disabled'; return 'disabled';
}; };
const isRowClickable = Boolean(onLabelClick) && !isEditing; const isRowClickable = Boolean(onLabelClick) && !isEditing;
const programTypeKey = getProgramType(programType);
const programTypeMeta = getProgramTypeMeta(programTypeKey);
const maxVisibleSteps = 4; const maxVisibleSteps = 4;
const [stepWindowStart, setStepWindowStart] = useState(0); const [stepWindowStart, setStepWindowStart] = useState(0);
const maxWindowStart = Math.max(0, steps.length - maxVisibleSteps); const maxWindowStart = Math.max(0, steps.length - maxVisibleSteps);
@@ -591,7 +619,7 @@ function FlowRow({
return ( return (
<section <section
onClick={isRowClickable ? onLabelClick : undefined} onClick={isRowClickable ? onLabelClick : undefined}
className={`rounded-[28px] border border-white/70 bg-white/75 p-5 shadow-[0_18px_50px_rgba(15,23,42,0.08)] backdrop-blur ${ className={`rounded-[28px] border p-5 shadow-[0_18px_50px_rgba(15,23,42,0.08)] backdrop-blur ${programTypeMeta.rowClass} ${
isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : '' isRowClickable ? 'cursor-pointer transition hover:-translate-y-0.5 hover:shadow-[0_22px_60px_rgba(15,23,42,0.12)]' : ''
}`} }`}
> >
@@ -628,6 +656,31 @@ function FlowRow({
프로그램 삭제 프로그램 삭제
</button> </button>
)} )}
{isEditing && onProgramTypeChange ? (
<div className="flex rounded-full bg-white/80 p-1 text-[11px] font-black shadow-sm ring-1 ring-slate-200">
{Object.entries(programTypeOptions).map(([typeKey, typeMeta]) => (
<button
key={typeKey}
type="button"
onClick={(event) => {
event.stopPropagation();
onProgramTypeChange(typeKey);
}}
className={`rounded-full px-2.5 py-1 transition ${
programTypeKey === typeKey
? typeMeta.badgeClass
: 'text-slate-400 hover:bg-slate-50'
}`}
>
{typeMeta.shortLabel}
</button>
))}
</div>
) : (
<span className={`rounded-full px-2.5 py-1 text-[11px] font-black ring-1 ${programTypeMeta.badgeClass}`}>
{programTypeMeta.label}
</span>
)}
</div> </div>
{isEditing && onDescriptionChange ? ( {isEditing && onDescriptionChange ? (
<textarea <textarea
@@ -1285,10 +1338,11 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
{graphPrograms.map((program) => { {graphPrograms.map((program) => {
const position = nodePositions[program.id]; const position = nodePositions[program.id];
if (!position) return null; if (!position) return null;
const typeMeta = getProgramTypeMeta(program.programType);
return ( return (
<div <div
key={program.id} key={program.id}
className="absolute flex flex-col items-center justify-center rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50 px-3 text-center shadow-sm" className={`absolute flex flex-col items-center justify-center rounded-2xl border px-3 text-center shadow-sm ${typeMeta.rowClass}`}
style={{ style={{
left: `${(position.x / graphWidth) * 100}%`, left: `${(position.x / graphWidth) * 100}%`,
top: `${(position.y / graphHeight) * 100}%`, top: `${(position.y / graphHeight) * 100}%`,
@@ -1297,6 +1351,9 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
}} }}
> >
<p className="text-sm font-extrabold text-slate-950">{program.name}</p> <p className="text-sm font-extrabold text-slate-950">{program.name}</p>
<span className={`mt-1 rounded-full px-2 py-0.5 text-[10px] font-black ring-1 ${typeMeta.badgeClass}`}>
{typeMeta.shortLabel}
</span>
</div> </div>
); );
})} })}
@@ -1420,6 +1477,7 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
const isExpanded = expandedProgramIds.has(program.id); const isExpanded = expandedProgramIds.has(program.id);
const isRepeated = visitedIds.has(program.id); const isRepeated = visitedIds.has(program.id);
const hasMultipleInputs = (program.predecessors ?? []).length > 1; const hasMultipleInputs = (program.predecessors ?? []).length > 1;
const typeMeta = getProgramTypeMeta(program.programType);
const nextVisitedIds = new Set(visitedIds); const nextVisitedIds = new Set(visitedIds);
nextVisitedIds.add(program.id); nextVisitedIds.add(program.id);
@@ -1454,13 +1512,19 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
onClick={() => onProgramClick(program.id)} onClick={() => onProgramClick(program.id)}
className="min-w-0 flex-1 overflow-hidden rounded-xl bg-white/85 px-3 py-2 text-left shadow-sm ring-1 ring-slate-100 transition hover:-translate-y-0.5 hover:bg-white hover:shadow" className="min-w-0 flex-1 overflow-hidden rounded-xl bg-white/85 px-3 py-2 text-left shadow-sm ring-1 ring-slate-100 transition hover:-translate-y-0.5 hover:bg-white hover:shadow"
> >
<span className="block truncate text-[13px] font-black text-slate-900"> <span className="flex min-w-0 items-center gap-1.5">
{program.name} <span className={`h-2 w-2 shrink-0 rounded-full ${typeMeta.dotClass}`} />
<span className="block truncate text-[13px] font-black text-slate-900">
{program.name}
</span>
</span> </span>
<span className="mt-0.5 block truncate text-[11px] font-semibold text-slate-500"> <span className="mt-0.5 block truncate text-[11px] font-semibold text-slate-500">
{program.description} {program.description}
</span> </span>
</button> </button>
<span className={`rounded-full px-2 py-1 text-[10px] font-black ring-1 ${typeMeta.badgeClass}`}>
{typeMeta.shortLabel}
</span>
{hasMultipleInputs && ( {hasMultipleInputs && (
<span className="rounded-full bg-violet-50 px-2 py-1 text-[10px] font-black text-violet-600 ring-1 ring-violet-100"> <span className="rounded-full bg-violet-50 px-2 py-1 text-[10px] font-black text-violet-600 ring-1 ring-violet-100">
다중 다중
@@ -1521,6 +1585,7 @@ export default function App() {
description: content.cheonjiin.description, description: content.cheonjiin.description,
steps: editableCheonjiinFlow, steps: editableCheonjiinFlow,
format: content.cheonjiin.format, format: content.cheonjiin.format,
programType: getProgramType(content.cheonjiin.programType),
predecessors: content.cheonjiin.predecessors ?? [], predecessors: content.cheonjiin.predecessors ?? [],
successors: content.cheonjiin.successors ?? [], successors: content.cheonjiin.successors ?? [],
accent: { accent: {
@@ -1536,6 +1601,7 @@ export default function App() {
description: content.wayPrimal.description, description: content.wayPrimal.description,
steps: editableWayPrimalFlow, steps: editableWayPrimalFlow,
format: content.wayPrimal.format, format: content.wayPrimal.format,
programType: getProgramType(content.wayPrimal.programType),
predecessors: content.wayPrimal.predecessors ?? [], predecessors: content.wayPrimal.predecessors ?? [],
successors: content.wayPrimal.successors ?? [], successors: content.wayPrimal.successors ?? [],
accent: { accent: {
@@ -1551,6 +1617,7 @@ export default function App() {
description: program.description, description: program.description,
steps: normalizeProgramSteps(program), steps: normalizeProgramSteps(program),
format: program.format, format: program.format,
programType: getProgramType(program.programType),
predecessors: program.predecessors ?? [], predecessors: program.predecessors ?? [],
successors: program.successors ?? [], successors: program.successors ?? [],
accent: { accent: {
@@ -1939,6 +2006,7 @@ export default function App() {
], ],
format: '', format: '',
deliverables: ['성과물'], deliverables: ['성과물'],
programType: 'internal',
predecessors: [], predecessors: [],
successors: [], successors: [],
linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계' linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계'
@@ -2202,6 +2270,8 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('cheonjiin', index, field, value)} onStepChange={(index, field, value) => updateStep('cheonjiin', index, field, value)}
onLabelChange={(value) => updateProgramTitle('cheonjiin', 'name', value)} onLabelChange={(value) => updateProgramTitle('cheonjiin', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('cheonjiin', 'description', value)} onDescriptionChange={(value) => updateProgramTitle('cheonjiin', 'description', value)}
programType={content.cheonjiin.programType}
onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', value)}
format={content.cheonjiin.format} format={content.cheonjiin.format}
onFormatChange={(value) => updateFormat('cheonjiin', value)} onFormatChange={(value) => updateFormat('cheonjiin', value)}
deliverables={content.cheonjiin.deliverables} deliverables={content.cheonjiin.deliverables}
@@ -2244,6 +2314,8 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('wayPrimal', index, field, value)} onStepChange={(index, field, value) => updateStep('wayPrimal', index, field, value)}
onLabelChange={(value) => updateProgramTitle('wayPrimal', 'name', value)} onLabelChange={(value) => updateProgramTitle('wayPrimal', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('wayPrimal', 'description', value)} onDescriptionChange={(value) => updateProgramTitle('wayPrimal', 'description', value)}
programType={content.wayPrimal.programType}
onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', value)}
format={content.wayPrimal.format} format={content.wayPrimal.format}
onFormatChange={(value) => updateFormat('wayPrimal', value)} onFormatChange={(value) => updateFormat('wayPrimal', value)}
deliverables={content.wayPrimal.deliverables ?? []} deliverables={content.wayPrimal.deliverables ?? []}
@@ -2333,6 +2405,8 @@ export default function App() {
onStepChange={(index, field, value) => updateProgramStep(programIndex, index, field, value)} onStepChange={(index, field, value) => updateProgramStep(programIndex, index, field, value)}
onLabelChange={(value) => updateProgramTitle(program.id, 'name', value)} onLabelChange={(value) => updateProgramTitle(program.id, 'name', value)}
onDescriptionChange={(value) => updateProgramTitle(program.id, 'description', value)} onDescriptionChange={(value) => updateProgramTitle(program.id, 'description', value)}
programType={program.programType}
onProgramTypeChange={(value) => updateProgram(programIndex, 'programType', value)}
format={program.format} format={program.format}
onFormatChange={(value) => updateProgram(programIndex, 'format', value)} onFormatChange={(value) => updateProgram(programIndex, 'format', value)}
deliverables={program.deliverables ?? []} deliverables={program.deliverables ?? []}