atomsystem 0.4.3 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -52
- package/components/blocks/BlockDetailPageContent.tsx +337 -0
- package/components/blocks/BlockViewerCard.tsx +123 -123
- package/components/blocks/BlocksSidebar.tsx +93 -0
- package/components/blocks/CardListBlock.tsx +101 -196
- package/components/blocks/FileExplorerBlock.tsx +114 -446
- package/components/blocks/InteractiveChatBlock.tsx +107 -123
- package/components/blocks/MetricStatsBlock.tsx +78 -106
- package/components/blocks/PricingPlanBlock.tsx +88 -144
- package/components/blocks/RealtimeChartBlock.tsx +131 -250
- package/components/blocks/SliderShowcaseBlock.tsx +105 -305
- package/components/ui/Container.tsx +45 -0
- package/components/ui/Layout.tsx +220 -0
- package/components/ui/Switch.tsx +39 -0
- package/components/ui/Table.tsx +88 -0
- package/components/ui/Tabs.tsx +58 -0
- package/components/ui/index.ts +5 -0
- package/css/atom-ui.css +41 -816
- package/dist/cli.cjs +633 -41
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +630 -39
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +176 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +176 -3
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/presets/agents.guide.md +49 -0
- package/presets/blocks.md +101 -0
- package/presets/components.md +142 -0
- package/tokens/token.json +63 -1
- package/tokens/tokens.json +63 -1
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { useState, useRef, useEffect } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { Card, Button, Input, Badge } from '@/components/ui';
|
|
5
|
+
import {
|
|
6
|
+
Send,
|
|
7
|
+
Bot,
|
|
8
|
+
User,
|
|
9
|
+
Paperclip,
|
|
10
|
+
Check,
|
|
11
|
+
Plus,
|
|
12
|
+
} from 'lucide-react';
|
|
5
13
|
|
|
6
14
|
interface ChatMessage {
|
|
7
15
|
id: string;
|
|
@@ -11,10 +19,10 @@ interface ChatMessage {
|
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
const QUICK_PROMPTS = [
|
|
14
|
-
'
|
|
15
|
-
'
|
|
16
|
-
'
|
|
17
|
-
'
|
|
22
|
+
'디자인 토큰 어떻게 설정하나요?',
|
|
23
|
+
'4pt 그리드 스페이싱 규칙',
|
|
24
|
+
'다크모드 컬러 전환',
|
|
25
|
+
'0KB 런타임 CSS 빌드',
|
|
18
26
|
];
|
|
19
27
|
|
|
20
28
|
export function InteractiveChatBlock() {
|
|
@@ -22,62 +30,59 @@ export function InteractiveChatBlock() {
|
|
|
22
30
|
{
|
|
23
31
|
id: 'm1',
|
|
24
32
|
sender: 'assistant',
|
|
25
|
-
text: '
|
|
26
|
-
timestamp: '
|
|
33
|
+
text: '안녕하세요. 디자인 토큰 AI 어시스턴트입니다. 색상 시스템, 스페이싱 규격, 레이아웃에 관해 궁금한 점을 질문해보세요.',
|
|
34
|
+
timestamp: '10:00 AM',
|
|
27
35
|
},
|
|
28
36
|
{
|
|
29
37
|
id: 'm2',
|
|
30
38
|
sender: 'user',
|
|
31
|
-
text: '
|
|
32
|
-
timestamp: '
|
|
39
|
+
text: '브랜드 primary 색상 토큰을 어떻게 바꾸나요?',
|
|
40
|
+
timestamp: '10:01 AM',
|
|
33
41
|
},
|
|
34
42
|
{
|
|
35
43
|
id: 'm3',
|
|
36
44
|
sender: 'assistant',
|
|
37
|
-
text: '
|
|
38
|
-
timestamp: '
|
|
45
|
+
text: '우측 상단의 테마 에디터를 열거나, :root의 --atom-primary CSS 변수를 원하는 색상 코드로 지정하면 즉시 모든 컴포넌트에 반영됩니다.',
|
|
46
|
+
timestamp: '10:01 AM',
|
|
39
47
|
},
|
|
40
48
|
]);
|
|
41
49
|
|
|
42
|
-
const [
|
|
50
|
+
const [inputVal, setInputVal] = useState('');
|
|
43
51
|
const [isTyping, setIsTyping] = useState(false);
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const scrollToBottom = () => {
|
|
47
|
-
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
48
|
-
};
|
|
52
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
49
53
|
|
|
50
54
|
useEffect(() => {
|
|
51
|
-
|
|
55
|
+
if (scrollRef.current) {
|
|
56
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
57
|
+
}
|
|
52
58
|
}, [messages, isTyping]);
|
|
53
59
|
|
|
54
|
-
const
|
|
55
|
-
const text = textToSend ||
|
|
60
|
+
const handleSend = (textToSend?: string) => {
|
|
61
|
+
const text = textToSend || inputVal;
|
|
56
62
|
if (!text.trim()) return;
|
|
57
63
|
|
|
58
64
|
const now = new Date();
|
|
59
|
-
const timeStr = `${now.getHours()
|
|
65
|
+
const timeStr = `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
60
66
|
|
|
61
|
-
const
|
|
62
|
-
id: `
|
|
67
|
+
const newMsg: ChatMessage = {
|
|
68
|
+
id: `user-${Date.now()}`,
|
|
63
69
|
sender: 'user',
|
|
64
|
-
text: text
|
|
70
|
+
text: text,
|
|
65
71
|
timestamp: timeStr,
|
|
66
72
|
};
|
|
67
73
|
|
|
68
|
-
setMessages((prev) => [...prev,
|
|
69
|
-
if (!textToSend)
|
|
74
|
+
setMessages((prev) => [...prev, newMsg]);
|
|
75
|
+
if (!textToSend) setInputVal('');
|
|
70
76
|
setIsTyping(true);
|
|
71
77
|
|
|
72
|
-
// AI 자동 답변 시뮬레이션
|
|
73
78
|
setTimeout(() => {
|
|
74
|
-
let replyText =
|
|
75
|
-
if (text.includes('
|
|
76
|
-
replyText = '브랜드
|
|
79
|
+
let replyText = `"${text}"에 대한 디자인 토큰 가이드입니다. AtomDesignToken의 W3C DTCG 표준 규격에 따라 모든 변수는 CSS 변수(--atom-*)로 안전하게 컴파일됩니다.`;
|
|
80
|
+
if (text.includes('primary') || text.includes('색상')) {
|
|
81
|
+
replyText = 'Primary 브랜드 토큰은 css/atom-ui.css의 --atom-primary 변수로 선언되어 있으며, Tailwind v4 및 런타임 없는 순수 CSS로 상속됩니다.';
|
|
77
82
|
} else if (text.includes('그리드') || text.includes('스페이싱')) {
|
|
78
|
-
replyText = '스페이싱 토큰은 4px
|
|
79
|
-
} else if (text.includes('
|
|
80
|
-
replyText = '최상위 html 태그의
|
|
83
|
+
replyText = '스페이싱 토큰은 4px 배수 규격(--atom-space-1: 4px, --atom-space-2: 8px, --atom-space-4: 16px)으로 엄격히 관리됩니다.';
|
|
84
|
+
} else if (text.includes('다크')) {
|
|
85
|
+
replyText = '최상위 html 태그의 data-theme="dark" 속성을 통해 라이트/다크 시맨틱 토큰 매트릭스가 즉시 스위칭됩니다.';
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
const newBotMsg: ChatMessage = {
|
|
@@ -89,145 +94,124 @@ export function InteractiveChatBlock() {
|
|
|
89
94
|
|
|
90
95
|
setMessages((prev) => [...prev, newBotMsg]);
|
|
91
96
|
setIsTyping(false);
|
|
92
|
-
},
|
|
97
|
+
}, 800);
|
|
93
98
|
};
|
|
94
99
|
|
|
95
100
|
return (
|
|
96
|
-
<
|
|
97
|
-
{/* Chat Header */}
|
|
98
|
-
<div className="px-
|
|
99
|
-
<div className="flex items-center gap-
|
|
100
|
-
<div className="
|
|
101
|
-
<
|
|
102
|
-
<Bot className="w-5 h-5" />
|
|
103
|
-
</div>
|
|
104
|
-
<span className="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full bg-emerald-500 border-2 border-(--bg-primary)" />
|
|
101
|
+
<Card className="flex flex-col h-[480px] overflow-hidden bg-(--bg-primary) border border-(--border-subtle) transition-colors">
|
|
102
|
+
{/* 1. Chat Header */}
|
|
103
|
+
<div className="px-4 py-3 border-b border-(--border-subtle) flex items-center justify-between">
|
|
104
|
+
<div className="flex items-center gap-2">
|
|
105
|
+
<div className="w-7 h-7 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle) text-(--text-primary) flex items-center justify-center font-bold text-xs">
|
|
106
|
+
<Bot className="w-3.5 h-3.5" />
|
|
105
107
|
</div>
|
|
106
108
|
<div>
|
|
107
|
-
<div className="
|
|
108
|
-
|
|
109
|
-
<span className="px-1.5 py-0.2 rounded text-[10px] font-semibold bg-(--accent-primary)/10 text-(--accent-primary)">
|
|
110
|
-
온라인
|
|
111
|
-
</span>
|
|
109
|
+
<div className="text-xs font-semibold text-(--text-primary)">
|
|
110
|
+
디자인 토큰 어시스턴트
|
|
112
111
|
</div>
|
|
113
|
-
<
|
|
112
|
+
<div className="text-[11px] text-(--text-muted)">온디맨드 응답</div>
|
|
114
113
|
</div>
|
|
115
114
|
</div>
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
</button>
|
|
126
|
-
</div>
|
|
115
|
+
|
|
116
|
+
<Button
|
|
117
|
+
variant="outline"
|
|
118
|
+
size="sm"
|
|
119
|
+
onClick={() => setMessages(messages.slice(0, 1))}
|
|
120
|
+
className="text-xs py-1 px-2.5 h-auto"
|
|
121
|
+
>
|
|
122
|
+
대화 초기화
|
|
123
|
+
</Button>
|
|
127
124
|
</div>
|
|
128
125
|
|
|
129
|
-
{/* Messages Scroll
|
|
130
|
-
<div className="flex-1 p-4
|
|
126
|
+
{/* 2. Messages Scroll Area */}
|
|
127
|
+
<div ref={scrollRef} className="flex-1 p-4 overflow-y-auto space-y-3.5 bg-(--bg-primary)">
|
|
131
128
|
{messages.map((m) => {
|
|
132
129
|
const isUser = m.sender === 'user';
|
|
133
130
|
return (
|
|
134
131
|
<div
|
|
135
132
|
key={m.id}
|
|
136
|
-
className={`flex items-
|
|
133
|
+
className={`flex items-start gap-2.5 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}
|
|
137
134
|
>
|
|
138
|
-
{/* Avatar */}
|
|
139
135
|
<div
|
|
140
|
-
className={`w-
|
|
136
|
+
className={`w-6 h-6 rounded-(--radius-sm) flex items-center justify-center text-xs shrink-0 ${
|
|
141
137
|
isUser
|
|
142
|
-
? 'bg-(--
|
|
143
|
-
: 'bg-(--
|
|
138
|
+
? 'bg-(--text-primary) text-(--bg-primary)'
|
|
139
|
+
: 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
|
|
144
140
|
}`}
|
|
145
141
|
>
|
|
146
|
-
{isUser ? <User className="w-
|
|
142
|
+
{isUser ? <User className="w-3 h-3" /> : <Bot className="w-3 h-3" />}
|
|
147
143
|
</div>
|
|
148
144
|
|
|
149
|
-
{
|
|
150
|
-
<div className={`max-w-[78%] flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
|
|
145
|
+
<div className={`max-w-[75%] space-y-1 ${isUser ? 'items-end' : 'items-start'}`}>
|
|
151
146
|
<div
|
|
152
|
-
className={`
|
|
147
|
+
className={`p-3 rounded-(--radius) text-xs leading-relaxed ${
|
|
153
148
|
isUser
|
|
154
|
-
? 'bg-(--
|
|
155
|
-
: 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)
|
|
149
|
+
? 'bg-(--text-primary) text-(--bg-primary) font-medium'
|
|
150
|
+
: 'bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle)'
|
|
156
151
|
}`}
|
|
157
152
|
>
|
|
158
153
|
{m.text}
|
|
159
154
|
</div>
|
|
160
|
-
<div className=
|
|
161
|
-
|
|
162
|
-
{isUser && <CheckCheck className="w-3 h-3 text-(--accent-primary)" />}
|
|
155
|
+
<div className={`text-[11px] text-(--text-muted) px-1 ${isUser ? 'text-right' : 'text-left'}`}>
|
|
156
|
+
{m.timestamp}
|
|
163
157
|
</div>
|
|
164
158
|
</div>
|
|
165
159
|
</div>
|
|
166
160
|
);
|
|
167
161
|
})}
|
|
168
162
|
|
|
169
|
-
{/* Typing Pulse Indicator */}
|
|
170
163
|
{isTyping && (
|
|
171
|
-
<div className="flex items-center gap-2
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
</div>
|
|
175
|
-
<div className="bg-(--bg-secondary) border border-(--border-subtle) px-3 py-2 rounded-2xl rounded-bl-xs flex items-center gap-1">
|
|
176
|
-
<span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
177
|
-
<span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
178
|
-
<span className="w-2 h-2 rounded-full bg-(--accent-primary) animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
179
|
-
</div>
|
|
164
|
+
<div className="flex items-center gap-2 text-xs text-(--text-muted)">
|
|
165
|
+
<Bot className="w-3.5 h-3.5" />
|
|
166
|
+
<span>확인 중...</span>
|
|
180
167
|
</div>
|
|
181
168
|
)}
|
|
182
|
-
<div ref={messagesEndRef} />
|
|
183
169
|
</div>
|
|
184
170
|
|
|
185
|
-
{/* Quick
|
|
186
|
-
<div className="px-
|
|
187
|
-
{QUICK_PROMPTS.map((
|
|
188
|
-
<
|
|
171
|
+
{/* 3. Quick Suggestion Pills */}
|
|
172
|
+
<div className="px-3 py-2 border-t border-(--border-subtle) flex items-center gap-1.5 overflow-x-auto scrollbar-none">
|
|
173
|
+
{QUICK_PROMPTS.map((q, idx) => (
|
|
174
|
+
<Button
|
|
189
175
|
key={idx}
|
|
190
|
-
|
|
191
|
-
|
|
176
|
+
variant="outline"
|
|
177
|
+
size="sm"
|
|
178
|
+
onClick={() => handleSend(q)}
|
|
179
|
+
className="shrink-0 text-xs py-1 px-2.5 h-auto whitespace-nowrap"
|
|
192
180
|
>
|
|
193
|
-
{
|
|
194
|
-
</
|
|
181
|
+
{q}
|
|
182
|
+
</Button>
|
|
195
183
|
))}
|
|
196
184
|
</div>
|
|
197
185
|
|
|
198
|
-
{/* Input Bar */}
|
|
199
|
-
<div className="p-3
|
|
200
|
-
<
|
|
201
|
-
onSubmit={(e) => {
|
|
202
|
-
e.preventDefault();
|
|
203
|
-
handleSendMessage();
|
|
204
|
-
}}
|
|
205
|
-
className="flex items-center gap-2"
|
|
206
|
-
>
|
|
207
|
-
<button
|
|
208
|
-
type="button"
|
|
209
|
-
className="p-2 text-(--text-muted) hover:text-(--text-primary) hover:bg-(--bg-secondary) rounded-xl transition-colors shrink-0"
|
|
210
|
-
title="첨부 파일"
|
|
211
|
-
>
|
|
212
|
-
<Paperclip className="w-4 h-4" />
|
|
213
|
-
</button>
|
|
186
|
+
{/* 4. Bottom Input Bar composed with Input & Button */}
|
|
187
|
+
<div className="p-3 border-t border-(--border-subtle) bg-(--bg-primary)">
|
|
188
|
+
<div className="flex items-center gap-2">
|
|
214
189
|
<input
|
|
215
190
|
type="text"
|
|
216
|
-
value={
|
|
217
|
-
onChange={(e) =>
|
|
218
|
-
|
|
219
|
-
|
|
191
|
+
value={inputVal}
|
|
192
|
+
onChange={(e) => setInputVal(e.target.value)}
|
|
193
|
+
onKeyDown={(e) => {
|
|
194
|
+
if (e.key === 'Enter') {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
handleSend();
|
|
197
|
+
}
|
|
198
|
+
}}
|
|
199
|
+
placeholder="디자인 시스템이나 토큰 규칙에 관해 질문하세요..."
|
|
200
|
+
className="flex-1 text-xs px-3 py-2 rounded-(--radius-sm) bg-(--bg-secondary) text-(--text-primary) border border-(--border-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
|
|
220
201
|
/>
|
|
221
|
-
<
|
|
222
|
-
type="
|
|
223
|
-
|
|
224
|
-
|
|
202
|
+
<Button
|
|
203
|
+
type="button"
|
|
204
|
+
variant="primary"
|
|
205
|
+
size="sm"
|
|
206
|
+
onClick={() => handleSend()}
|
|
207
|
+
aria-label="전송"
|
|
225
208
|
title="전송"
|
|
209
|
+
className="p-2 h-auto"
|
|
226
210
|
>
|
|
227
|
-
<Send className="w-
|
|
228
|
-
</
|
|
229
|
-
</
|
|
211
|
+
<Send className="w-3.5 h-3.5" />
|
|
212
|
+
</Button>
|
|
213
|
+
</div>
|
|
230
214
|
</div>
|
|
231
|
-
</
|
|
215
|
+
</Card>
|
|
232
216
|
);
|
|
233
217
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React from 'react';
|
|
4
|
+
import { Card, Badge } from '@/components/ui';
|
|
4
5
|
import {
|
|
5
6
|
TrendingUp,
|
|
6
7
|
TrendingDown,
|
|
@@ -8,7 +9,6 @@ import {
|
|
|
8
9
|
Users,
|
|
9
10
|
Target,
|
|
10
11
|
Server,
|
|
11
|
-
ArrowUpRight,
|
|
12
12
|
} from 'lucide-react';
|
|
13
13
|
|
|
14
14
|
interface MetricItem {
|
|
@@ -25,9 +25,9 @@ interface MetricItem {
|
|
|
25
25
|
const METRICS: MetricItem[] = [
|
|
26
26
|
{
|
|
27
27
|
id: 'm-arr',
|
|
28
|
-
label: '
|
|
29
|
-
subLabel: '
|
|
30
|
-
value: '₩
|
|
28
|
+
label: '월간 반복 매출 (MRR)',
|
|
29
|
+
subLabel: '구독 플랜 총액',
|
|
30
|
+
value: '₩48,250,000',
|
|
31
31
|
change: '+18.4%',
|
|
32
32
|
isPositive: true,
|
|
33
33
|
icon: 'dollar',
|
|
@@ -35,9 +35,9 @@ const METRICS: MetricItem[] = [
|
|
|
35
35
|
},
|
|
36
36
|
{
|
|
37
37
|
id: 'm-mau',
|
|
38
|
-
label: '
|
|
39
|
-
subLabel: '토큰 CLI
|
|
40
|
-
value: '49,210
|
|
38
|
+
label: '활성 개발자 (MAU)',
|
|
39
|
+
subLabel: '토큰 CLI 활성 호출자',
|
|
40
|
+
value: '49,210명',
|
|
41
41
|
change: '+12.6%',
|
|
42
42
|
isPositive: true,
|
|
43
43
|
icon: 'users',
|
|
@@ -45,23 +45,23 @@ const METRICS: MetricItem[] = [
|
|
|
45
45
|
},
|
|
46
46
|
{
|
|
47
47
|
id: 'm-cvr',
|
|
48
|
-
label: '
|
|
49
|
-
subLabel: '
|
|
50
|
-
value: '4.92
|
|
48
|
+
label: '유료 플랜 전환율',
|
|
49
|
+
subLabel: '무료에서 Pro 업그레이드',
|
|
50
|
+
value: '4.92%',
|
|
51
51
|
change: '-0.3%',
|
|
52
52
|
isPositive: false,
|
|
53
53
|
icon: 'target',
|
|
54
|
-
sparkline: [60, 58, 62,
|
|
54
|
+
sparkline: [60, 58, 62, 55, 50, 52, 48, 45],
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
|
-
id: 'm-
|
|
58
|
-
label: 'API
|
|
59
|
-
subLabel: '
|
|
60
|
-
value: '99.
|
|
61
|
-
change: '+0.
|
|
57
|
+
id: 'm-uptime',
|
|
58
|
+
label: 'API 가동률 & SLA',
|
|
59
|
+
subLabel: '엣지 컴파일 노드 헬스',
|
|
60
|
+
value: '99.98%',
|
|
61
|
+
change: '+0.02%',
|
|
62
62
|
isPositive: true,
|
|
63
63
|
icon: 'server',
|
|
64
|
-
sparkline: [
|
|
64
|
+
sparkline: [95, 96, 98, 97, 99, 99, 100, 100],
|
|
65
65
|
},
|
|
66
66
|
];
|
|
67
67
|
|
|
@@ -69,110 +69,82 @@ export function MetricStatsBlock() {
|
|
|
69
69
|
const getIcon = (type: MetricItem['icon']) => {
|
|
70
70
|
switch (type) {
|
|
71
71
|
case 'dollar':
|
|
72
|
-
return <DollarSign className="w-
|
|
72
|
+
return <DollarSign className="w-3.5 h-3.5 text-(--accent-primary)" />;
|
|
73
73
|
case 'users':
|
|
74
|
-
return <Users className="w-
|
|
74
|
+
return <Users className="w-3.5 h-3.5 text-blue-500" />;
|
|
75
75
|
case 'target':
|
|
76
|
-
return <Target className="w-
|
|
76
|
+
return <Target className="w-3.5 h-3.5 text-amber-500" />;
|
|
77
77
|
case 'server':
|
|
78
|
-
return <Server className="w-
|
|
78
|
+
return <Server className="w-3.5 h-3.5 text-emerald-500" />;
|
|
79
79
|
}
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
-
const renderSparkline = (points: number[], isPositive: boolean) => {
|
|
83
|
-
const min = Math.min(...points);
|
|
84
|
-
const max = Math.max(...points);
|
|
85
|
-
const range = max - min || 1;
|
|
86
|
-
const width = 80;
|
|
87
|
-
const height = 28;
|
|
88
|
-
|
|
89
|
-
const coords = points.map((p, idx) => {
|
|
90
|
-
const x = (idx / (points.length - 1)) * width;
|
|
91
|
-
const y = height - ((p - min) / range) * (height - 6) - 3;
|
|
92
|
-
return `${idx === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
|
93
|
-
}).join(' ');
|
|
94
|
-
|
|
95
|
-
const strokeColor = isPositive ? 'var(--atom-success, #10b981)' : 'var(--atom-destructive, #f04452)';
|
|
96
|
-
|
|
97
|
-
return (
|
|
98
|
-
<svg width={width} height={height} className="overflow-visible">
|
|
99
|
-
<path
|
|
100
|
-
d={coords}
|
|
101
|
-
fill="none"
|
|
102
|
-
stroke={strokeColor}
|
|
103
|
-
strokeWidth="2"
|
|
104
|
-
strokeLinecap="round"
|
|
105
|
-
strokeLinejoin="round"
|
|
106
|
-
/>
|
|
107
|
-
</svg>
|
|
108
|
-
);
|
|
109
|
-
};
|
|
110
|
-
|
|
111
82
|
return (
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</span>
|
|
119
|
-
<h3 className="text-lg font-bold text-(--text-primary) mt-0.5">
|
|
120
|
-
4대 핵심 비즈니스 KPI 지표 대시보드
|
|
121
|
-
</h3>
|
|
122
|
-
</div>
|
|
123
|
-
<div className="text-[11px] text-(--text-muted) flex items-center gap-1.5 mt-2 sm:mt-0">
|
|
124
|
-
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
|
125
|
-
<span>실시간 동기화 상태: 정상 (Latency 14ms)</span>
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
83
|
+
<Card className="overflow-hidden transition-colors">
|
|
84
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 divide-y sm:divide-y-0 sm:divide-x divide-(--border-subtle)">
|
|
85
|
+
{METRICS.map((m) => {
|
|
86
|
+
const maxVal = Math.max(...m.sparkline);
|
|
87
|
+
const minVal = Math.min(...m.sparkline);
|
|
88
|
+
const range = maxVal - minVal || 1;
|
|
128
89
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
|
146
|
-
: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
|
|
147
|
-
}`}
|
|
148
|
-
>
|
|
149
|
-
{metric.isPositive ? (
|
|
150
|
-
<TrendingUp className="w-3 h-3" />
|
|
151
|
-
) : (
|
|
152
|
-
<TrendingDown className="w-3 h-3" />
|
|
153
|
-
)}
|
|
154
|
-
<span>{metric.change}</span>
|
|
90
|
+
const points = m.sparkline
|
|
91
|
+
.map((val, idx) => {
|
|
92
|
+
const x = (idx / (m.sparkline.length - 1)) * 70;
|
|
93
|
+
const y = 24 - ((val - minVal) / range) * 20;
|
|
94
|
+
return `${x},${y}`;
|
|
95
|
+
})
|
|
96
|
+
.join(' ');
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div key={m.id} className="p-4 sm:p-5 flex flex-col justify-between space-y-3">
|
|
100
|
+
<div className="flex items-center justify-between">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<div className="p-1.5 rounded-(--radius-sm) bg-(--bg-secondary) border border-(--border-subtle)">
|
|
103
|
+
{getIcon(m.icon)}
|
|
104
|
+
</div>
|
|
105
|
+
<span className="text-xs text-(--text-secondary) font-medium">{m.label}</span>
|
|
155
106
|
</div>
|
|
156
107
|
</div>
|
|
157
108
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
109
|
+
<div className="flex items-baseline justify-between">
|
|
110
|
+
<div>
|
|
111
|
+
<div className="text-xl font-bold tracking-tight text-(--text-primary)">
|
|
112
|
+
{m.value}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="text-[11px] text-(--text-secondary) mt-0.5">{m.subLabel}</div>
|
|
115
|
+
</div>
|
|
161
116
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
117
|
+
{/* Mini SVG Sparkline */}
|
|
118
|
+
<div className="w-18 h-7">
|
|
119
|
+
<svg className="w-full h-full overflow-visible" viewBox="0 0 70 24">
|
|
120
|
+
<polyline
|
|
121
|
+
fill="none"
|
|
122
|
+
stroke="currentColor"
|
|
123
|
+
strokeWidth="1.5"
|
|
124
|
+
strokeLinecap="round"
|
|
125
|
+
strokeLinejoin="round"
|
|
126
|
+
points={points}
|
|
127
|
+
className={m.isPositive ? 'text-emerald-500' : 'text-rose-500'}
|
|
128
|
+
/>
|
|
129
|
+
</svg>
|
|
130
|
+
</div>
|
|
165
131
|
</div>
|
|
166
|
-
</div>
|
|
167
132
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
133
|
+
<div className="pt-2 border-t border-(--border-subtle) flex items-center justify-between text-[11px]">
|
|
134
|
+
<span className="text-(--text-secondary)">전월 동기 대비</span>
|
|
135
|
+
<span
|
|
136
|
+
className={`font-semibold flex items-center gap-0.5 ${
|
|
137
|
+
m.isPositive ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'
|
|
138
|
+
}`}
|
|
139
|
+
>
|
|
140
|
+
{m.isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
|
141
|
+
<span>{m.change}</span>
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
172
144
|
</div>
|
|
173
|
-
|
|
174
|
-
)
|
|
145
|
+
);
|
|
146
|
+
})}
|
|
175
147
|
</div>
|
|
176
|
-
</
|
|
148
|
+
</Card>
|
|
177
149
|
);
|
|
178
150
|
}
|