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 serverStateEndpoint = '/api/state';
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 = {
cheonjiin: {
@@ -184,6 +203,7 @@ const defaultContent = {
steps: cheonjiinFlow.map(({ title, feature, note }) => ({ title, feature, note })),
deliverables: cheonjiinDeliverables,
format: 'glb',
programType: 'internal',
predecessors: [],
successors: []
},
@@ -193,6 +213,7 @@ const defaultContent = {
steps: wayPrimalFlow.map(({ title, feature, note }) => ({ title, feature, note })),
format: '',
deliverables: ['기본설계 모델'],
programType: 'internal',
predecessors: [],
successors: [],
linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계'
@@ -206,6 +227,7 @@ function normalizeStoredContent(parsed) {
cheonjiin: {
...defaultContent.cheonjiin,
...(parsed.cheonjiin ?? {}),
programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType),
predecessors: parsed.cheonjiin?.predecessors ?? [],
successors: parsed.cheonjiin?.successors ?? [],
format:
@@ -216,6 +238,7 @@ function normalizeStoredContent(parsed) {
wayPrimal: {
...defaultContent.wayPrimal,
...(parsed.wayPrimal ?? {}),
programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType),
predecessors: parsed.wayPrimal?.predecessors ?? [],
successors: parsed.wayPrimal?.successors ?? [],
format:
@@ -225,6 +248,7 @@ function normalizeStoredContent(parsed) {
},
extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({
...program,
programType: getProgramType(program.programType),
predecessors: program.predecessors ?? [],
successors: program.successors ?? [],
linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계`
@@ -508,6 +532,8 @@ function FlowRow({
onDeliverableChange,
onLabelChange,
onDescriptionChange,
programType,
onProgramTypeChange,
onAddStep,
onRemoveStep,
onMoveStep,
@@ -523,6 +549,8 @@ function FlowRow({
return 'disabled';
};
const isRowClickable = Boolean(onLabelClick) && !isEditing;
const programTypeKey = getProgramType(programType);
const programTypeMeta = getProgramTypeMeta(programTypeKey);
const maxVisibleSteps = 4;
const [stepWindowStart, setStepWindowStart] = useState(0);
const maxWindowStart = Math.max(0, steps.length - maxVisibleSteps);
@@ -591,7 +619,7 @@ function FlowRow({
return (
<section
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)]' : ''
}`}
>
@@ -628,6 +656,31 @@ function FlowRow({
프로그램 삭제
</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>
{isEditing && onDescriptionChange ? (
<textarea
@@ -1285,10 +1338,11 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {
{graphPrograms.map((program) => {
const position = nodePositions[program.id];
if (!position) return null;
const typeMeta = getProgramTypeMeta(program.programType);
return (
<div
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={{
left: `${(position.x / graphWidth) * 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>
<span className={`mt-1 rounded-full px-2 py-0.5 text-[10px] font-black ring-1 ${typeMeta.badgeClass}`}>
{typeMeta.shortLabel}
</span>
</div>
);
})}
@@ -1420,6 +1477,7 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
const isExpanded = expandedProgramIds.has(program.id);
const isRepeated = visitedIds.has(program.id);
const hasMultipleInputs = (program.predecessors ?? []).length > 1;
const typeMeta = getProgramTypeMeta(program.programType);
const nextVisitedIds = new Set(visitedIds);
nextVisitedIds.add(program.id);
@@ -1454,13 +1512,19 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {
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"
>
<span className="block truncate text-[13px] font-black text-slate-900">
{program.name}
<span className="flex min-w-0 items-center gap-1.5">
<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 className="mt-0.5 block truncate text-[11px] font-semibold text-slate-500">
{program.description}
</span>
</button>
<span className={`rounded-full px-2 py-1 text-[10px] font-black ring-1 ${typeMeta.badgeClass}`}>
{typeMeta.shortLabel}
</span>
{hasMultipleInputs && (
<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,
steps: editableCheonjiinFlow,
format: content.cheonjiin.format,
programType: getProgramType(content.cheonjiin.programType),
predecessors: content.cheonjiin.predecessors ?? [],
successors: content.cheonjiin.successors ?? [],
accent: {
@@ -1536,6 +1601,7 @@ export default function App() {
description: content.wayPrimal.description,
steps: editableWayPrimalFlow,
format: content.wayPrimal.format,
programType: getProgramType(content.wayPrimal.programType),
predecessors: content.wayPrimal.predecessors ?? [],
successors: content.wayPrimal.successors ?? [],
accent: {
@@ -1551,6 +1617,7 @@ export default function App() {
description: program.description,
steps: normalizeProgramSteps(program),
format: program.format,
programType: getProgramType(program.programType),
predecessors: program.predecessors ?? [],
successors: program.successors ?? [],
accent: {
@@ -1939,6 +2006,7 @@ export default function App() {
],
format: '',
deliverables: ['성과물'],
programType: 'internal',
predecessors: [],
successors: [],
linkLabel: '이전 프로그램 산출물을 새 프로그램 입력으로 연계'
@@ -2202,6 +2270,8 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('cheonjiin', index, field, value)}
onLabelChange={(value) => updateProgramTitle('cheonjiin', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('cheonjiin', 'description', value)}
programType={content.cheonjiin.programType}
onProgramTypeChange={(value) => updateProgramTitle('cheonjiin', 'programType', value)}
format={content.cheonjiin.format}
onFormatChange={(value) => updateFormat('cheonjiin', value)}
deliverables={content.cheonjiin.deliverables}
@@ -2244,6 +2314,8 @@ export default function App() {
onStepChange={(index, field, value) => updateStep('wayPrimal', index, field, value)}
onLabelChange={(value) => updateProgramTitle('wayPrimal', 'name', value)}
onDescriptionChange={(value) => updateProgramTitle('wayPrimal', 'description', value)}
programType={content.wayPrimal.programType}
onProgramTypeChange={(value) => updateProgramTitle('wayPrimal', 'programType', value)}
format={content.wayPrimal.format}
onFormatChange={(value) => updateFormat('wayPrimal', value)}
deliverables={content.wayPrimal.deliverables ?? []}
@@ -2333,6 +2405,8 @@ export default function App() {
onStepChange={(index, field, value) => updateProgramStep(programIndex, index, field, value)}
onLabelChange={(value) => updateProgramTitle(program.id, 'name', value)}
onDescriptionChange={(value) => updateProgramTitle(program.id, 'description', value)}
programType={program.programType}
onProgramTypeChange={(value) => updateProgram(programIndex, 'programType', value)}
format={program.format}
onFormatChange={(value) => updateProgram(programIndex, 'format', value)}
deliverables={program.deliverables ?? []}