@agile-vibe-coding/avc 0.1.1 → 0.3.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/cli/agent-loader.js +21 -0
- package/cli/agents/agent-selector.md +152 -0
- package/cli/agents/architecture-recommender.md +418 -0
- package/cli/agents/code-implementer.md +117 -0
- package/cli/agents/code-validator.md +80 -0
- package/cli/agents/context-reviewer-epic.md +101 -0
- package/cli/agents/context-reviewer-story.md +92 -0
- package/cli/agents/context-writer-epic.md +145 -0
- package/cli/agents/context-writer-story.md +111 -0
- package/cli/agents/database-deep-dive.md +470 -0
- package/cli/agents/database-recommender.md +634 -0
- package/cli/agents/doc-distributor.md +176 -0
- package/cli/agents/doc-writer-epic.md +42 -0
- package/cli/agents/doc-writer-story.md +43 -0
- package/cli/agents/documentation-updater.md +203 -0
- package/cli/agents/duplicate-detector.md +110 -0
- package/cli/agents/epic-story-decomposer.md +559 -0
- package/cli/agents/feature-context-generator.md +91 -0
- package/cli/agents/gap-checker-epic.md +52 -0
- package/cli/agents/impact-checker-story.md +51 -0
- package/cli/agents/migration-guide-generator.md +305 -0
- package/cli/agents/mission-scope-generator.md +143 -0
- package/cli/agents/mission-scope-validator.md +146 -0
- package/cli/agents/project-context-extractor.md +122 -0
- package/cli/agents/project-documentation-creator.json +226 -0
- package/cli/agents/project-documentation-creator.md +595 -0
- package/cli/agents/question-prefiller.md +269 -0
- package/cli/agents/refiner-epic.md +39 -0
- package/cli/agents/refiner-story.md +42 -0
- package/cli/agents/scaffolding-generator.md +99 -0
- package/cli/agents/seed-validator.md +71 -0
- package/cli/agents/story-doc-enricher.md +133 -0
- package/cli/agents/story-scope-reviewer.md +147 -0
- package/cli/agents/story-splitter.md +83 -0
- package/cli/agents/suggestion-business-analyst.md +88 -0
- package/cli/agents/suggestion-deployment-architect.md +263 -0
- package/cli/agents/suggestion-product-manager.md +129 -0
- package/cli/agents/suggestion-security-specialist.md +156 -0
- package/cli/agents/suggestion-technical-architect.md +269 -0
- package/cli/agents/suggestion-ux-researcher.md +93 -0
- package/cli/agents/task-subtask-decomposer.md +188 -0
- package/cli/agents/validator-documentation.json +183 -0
- package/cli/agents/validator-documentation.md +455 -0
- package/cli/agents/validator-selector.md +211 -0
- package/cli/ansi-colors.js +21 -0
- package/cli/api-reference-tool.js +368 -0
- package/cli/build-docs.js +29 -8
- package/cli/ceremony-history.js +369 -0
- package/cli/checks/catalog.json +76 -0
- package/cli/checks/code/quality.json +26 -0
- package/cli/checks/code/testing.json +14 -0
- package/cli/checks/code/traceability.json +26 -0
- package/cli/checks/cross-refs/epic.json +171 -0
- package/cli/checks/cross-refs/story.json +149 -0
- package/cli/checks/epic/api.json +114 -0
- package/cli/checks/epic/backend.json +126 -0
- package/cli/checks/epic/cloud.json +126 -0
- package/cli/checks/epic/data.json +102 -0
- package/cli/checks/epic/database.json +114 -0
- package/cli/checks/epic/developer.json +182 -0
- package/cli/checks/epic/devops.json +174 -0
- package/cli/checks/epic/frontend.json +162 -0
- package/cli/checks/epic/mobile.json +102 -0
- package/cli/checks/epic/qa.json +90 -0
- package/cli/checks/epic/security.json +184 -0
- package/cli/checks/epic/solution-architect.json +192 -0
- package/cli/checks/epic/test-architect.json +90 -0
- package/cli/checks/epic/ui.json +102 -0
- package/cli/checks/epic/ux.json +90 -0
- package/cli/checks/fixes/epic-fix-template.md +10 -0
- package/cli/checks/fixes/story-fix-template.md +10 -0
- package/cli/checks/story/api.json +186 -0
- package/cli/checks/story/backend.json +102 -0
- package/cli/checks/story/cloud.json +102 -0
- package/cli/checks/story/data.json +210 -0
- package/cli/checks/story/database.json +102 -0
- package/cli/checks/story/developer.json +168 -0
- package/cli/checks/story/devops.json +102 -0
- package/cli/checks/story/frontend.json +174 -0
- package/cli/checks/story/mobile.json +102 -0
- package/cli/checks/story/qa.json +210 -0
- package/cli/checks/story/security.json +198 -0
- package/cli/checks/story/solution-architect.json +230 -0
- package/cli/checks/story/test-architect.json +210 -0
- package/cli/checks/story/ui.json +102 -0
- package/cli/checks/story/ux.json +102 -0
- package/cli/coding-order.js +401 -0
- package/cli/command-logger.js +49 -12
- package/cli/components/static-output.js +63 -0
- package/cli/console-output-manager.js +94 -0
- package/cli/dependency-checker.js +72 -0
- package/cli/docs-sync.js +306 -0
- package/cli/epic-story-validator.js +659 -0
- package/cli/evaluation-prompts.js +1008 -0
- package/cli/execution-context.js +195 -0
- package/cli/generate-summary-table.js +340 -0
- package/cli/init-model-config.js +704 -0
- package/cli/init.js +1737 -278
- package/cli/kanban-server-manager.js +227 -0
- package/cli/llm-claude.js +150 -1
- package/cli/llm-gemini.js +109 -0
- package/cli/llm-local.js +493 -0
- package/cli/llm-mock.js +233 -0
- package/cli/llm-openai.js +454 -0
- package/cli/llm-provider.js +379 -3
- package/cli/llm-token-limits.js +211 -0
- package/cli/llm-verifier.js +662 -0
- package/cli/llm-xiaomi.js +143 -0
- package/cli/message-constants.js +49 -0
- package/cli/message-manager.js +334 -0
- package/cli/message-types.js +96 -0
- package/cli/messaging-api.js +291 -0
- package/cli/micro-check-fixer.js +335 -0
- package/cli/micro-check-runner.js +449 -0
- package/cli/micro-check-scorer.js +148 -0
- package/cli/micro-check-validator.js +538 -0
- package/cli/model-pricing.js +192 -0
- package/cli/model-query-engine.js +468 -0
- package/cli/model-recommendation-analyzer.js +495 -0
- package/cli/model-selector.js +270 -0
- package/cli/output-buffer.js +107 -0
- package/cli/process-manager.js +73 -2
- package/cli/prompt-logger.js +57 -0
- package/cli/repl-ink.js +4625 -1094
- package/cli/repl-old.js +3 -4
- package/cli/seed-processor.js +962 -0
- package/cli/sprint-planning-processor.js +4162 -0
- package/cli/template-processor.js +2149 -105
- package/cli/templates/project.md +25 -8
- package/cli/templates/vitepress-config.mts.template +5 -4
- package/cli/token-tracker.js +547 -0
- package/cli/tools/generate-story-validators.js +317 -0
- package/cli/tools/generate-validators.js +669 -0
- package/cli/update-checker.js +19 -17
- package/cli/update-notifier.js +4 -4
- package/cli/validation-router.js +667 -0
- package/cli/verification-tracker.js +563 -0
- package/cli/worktree-runner.js +654 -0
- package/kanban/README.md +386 -0
- package/kanban/client/README.md +205 -0
- package/kanban/client/components.json +20 -0
- package/kanban/client/dist/assets/index-D_KC5EQT.css +1 -0
- package/kanban/client/dist/assets/index-DjY5zqW7.js +351 -0
- package/kanban/client/dist/index.html +16 -0
- package/kanban/client/dist/vite.svg +1 -0
- package/kanban/client/index.html +15 -0
- package/kanban/client/package-lock.json +9442 -0
- package/kanban/client/package.json +44 -0
- package/kanban/client/postcss.config.js +6 -0
- package/kanban/client/public/vite.svg +1 -0
- package/kanban/client/src/App.jsx +651 -0
- package/kanban/client/src/components/ProjectFileEditorPopup.jsx +117 -0
- package/kanban/client/src/components/ceremony/AskArchPopup.jsx +420 -0
- package/kanban/client/src/components/ceremony/AskModelPopup.jsx +629 -0
- package/kanban/client/src/components/ceremony/CeremonyWorkflowModal.jsx +1133 -0
- package/kanban/client/src/components/ceremony/EpicStorySelectionModal.jsx +254 -0
- package/kanban/client/src/components/ceremony/ProviderSwitcherButton.jsx +290 -0
- package/kanban/client/src/components/ceremony/SponsorCallModal.jsx +686 -0
- package/kanban/client/src/components/ceremony/SprintPlanningModal.jsx +838 -0
- package/kanban/client/src/components/ceremony/steps/ArchitectureStep.jsx +150 -0
- package/kanban/client/src/components/ceremony/steps/CompleteStep.jsx +136 -0
- package/kanban/client/src/components/ceremony/steps/DatabaseStep.jsx +202 -0
- package/kanban/client/src/components/ceremony/steps/DeploymentStep.jsx +123 -0
- package/kanban/client/src/components/ceremony/steps/MissionStep.jsx +106 -0
- package/kanban/client/src/components/ceremony/steps/ReviewAnswersStep.jsx +329 -0
- package/kanban/client/src/components/ceremony/steps/RunningStep.jsx +249 -0
- package/kanban/client/src/components/kanban/CardDetailModal.jsx +646 -0
- package/kanban/client/src/components/kanban/EpicSection.jsx +146 -0
- package/kanban/client/src/components/kanban/FilterToolbar.jsx +222 -0
- package/kanban/client/src/components/kanban/GroupingSelector.jsx +63 -0
- package/kanban/client/src/components/kanban/KanbanBoard.jsx +211 -0
- package/kanban/client/src/components/kanban/KanbanCard.jsx +147 -0
- package/kanban/client/src/components/kanban/KanbanColumn.jsx +90 -0
- package/kanban/client/src/components/kanban/RefineWorkItemPopup.jsx +784 -0
- package/kanban/client/src/components/kanban/RunButton.jsx +162 -0
- package/kanban/client/src/components/kanban/SeedButton.jsx +176 -0
- package/kanban/client/src/components/layout/LoadingScreen.jsx +82 -0
- package/kanban/client/src/components/process/ProcessMonitorBar.jsx +80 -0
- package/kanban/client/src/components/settings/AgentEditorPopup.jsx +171 -0
- package/kanban/client/src/components/settings/AgentsTab.jsx +381 -0
- package/kanban/client/src/components/settings/ApiKeysTab.jsx +142 -0
- package/kanban/client/src/components/settings/CeremonyModelsTab.jsx +105 -0
- package/kanban/client/src/components/settings/CheckEditorPopup.jsx +507 -0
- package/kanban/client/src/components/settings/CostThresholdsTab.jsx +95 -0
- package/kanban/client/src/components/settings/ModelPricingTab.jsx +269 -0
- package/kanban/client/src/components/settings/OpenAIAuthSection.jsx +412 -0
- package/kanban/client/src/components/settings/ServersTab.jsx +121 -0
- package/kanban/client/src/components/settings/SettingsModal.jsx +84 -0
- package/kanban/client/src/components/stats/CostModal.jsx +384 -0
- package/kanban/client/src/components/ui/badge.jsx +27 -0
- package/kanban/client/src/components/ui/dialog.jsx +121 -0
- package/kanban/client/src/components/ui/tabs.jsx +85 -0
- package/kanban/client/src/hooks/__tests__/useGrouping.test.js +232 -0
- package/kanban/client/src/hooks/useGrouping.js +177 -0
- package/kanban/client/src/hooks/useWebSocket.js +120 -0
- package/kanban/client/src/lib/__tests__/api.test.js +196 -0
- package/kanban/client/src/lib/__tests__/status-grouping.test.js +94 -0
- package/kanban/client/src/lib/api.js +515 -0
- package/kanban/client/src/lib/status-grouping.js +154 -0
- package/kanban/client/src/lib/utils.js +11 -0
- package/kanban/client/src/main.jsx +10 -0
- package/kanban/client/src/store/__tests__/kanbanStore.test.js +164 -0
- package/kanban/client/src/store/ceremonyStore.js +172 -0
- package/kanban/client/src/store/filterStore.js +201 -0
- package/kanban/client/src/store/kanbanStore.js +123 -0
- package/kanban/client/src/store/processStore.js +65 -0
- package/kanban/client/src/store/sprintPlanningStore.js +33 -0
- package/kanban/client/src/styles/globals.css +59 -0
- package/kanban/client/tailwind.config.js +77 -0
- package/kanban/client/vite.config.js +28 -0
- package/kanban/client/vitest.config.js +28 -0
- package/kanban/dev-start.sh +47 -0
- package/kanban/package.json +12 -0
- package/kanban/server/index.js +537 -0
- package/kanban/server/routes/ceremony.js +454 -0
- package/kanban/server/routes/costs.js +163 -0
- package/kanban/server/routes/openai-oauth.js +366 -0
- package/kanban/server/routes/processes.js +50 -0
- package/kanban/server/routes/settings.js +736 -0
- package/kanban/server/routes/websocket.js +281 -0
- package/kanban/server/routes/work-items.js +487 -0
- package/kanban/server/services/CeremonyService.js +1441 -0
- package/kanban/server/services/FileSystemScanner.js +95 -0
- package/kanban/server/services/FileWatcher.js +144 -0
- package/kanban/server/services/HierarchyBuilder.js +196 -0
- package/kanban/server/services/ProcessRegistry.js +122 -0
- package/kanban/server/services/TaskRunnerService.js +261 -0
- package/kanban/server/services/WorkItemReader.js +123 -0
- package/kanban/server/services/WorkItemRefineService.js +510 -0
- package/kanban/server/start.js +49 -0
- package/kanban/server/utils/kanban-logger.js +132 -0
- package/kanban/server/utils/markdown.js +91 -0
- package/kanban/server/utils/status-grouping.js +107 -0
- package/kanban/server/workers/run-task-worker.js +121 -0
- package/kanban/server/workers/seed-worker.js +94 -0
- package/kanban/server/workers/sponsor-call-worker.js +92 -0
- package/kanban/server/workers/sprint-planning-worker.js +212 -0
- package/package.json +19 -7
- package/cli/agents/documentation.md +0 -302
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { saveGeneralSettings } from '../../lib/api';
|
|
3
|
+
|
|
4
|
+
export function ServersTab({ settings, onSaved }) {
|
|
5
|
+
const [kanbanPort, setKanbanPort] = useState(String(settings.kanbanPort || 4174));
|
|
6
|
+
const [docsPort, setDocsPort] = useState(String(settings.docsPort || 4173));
|
|
7
|
+
const [boardTitle, setBoardTitle] = useState(settings.boardTitle || 'AVC Kanban Board');
|
|
8
|
+
const [portsStatus, setPortsStatus] = useState(null); // null | 'saving' | 'saved' | 'error'
|
|
9
|
+
const [titleStatus, setTitleStatus] = useState(null);
|
|
10
|
+
|
|
11
|
+
const handleSavePorts = async () => {
|
|
12
|
+
setPortsStatus('saving');
|
|
13
|
+
try {
|
|
14
|
+
await saveGeneralSettings({
|
|
15
|
+
kanbanPort: Number(kanbanPort),
|
|
16
|
+
docsPort: Number(docsPort),
|
|
17
|
+
});
|
|
18
|
+
setPortsStatus('saved');
|
|
19
|
+
onSaved();
|
|
20
|
+
setTimeout(() => setPortsStatus(null), 2000);
|
|
21
|
+
} catch {
|
|
22
|
+
setPortsStatus('error');
|
|
23
|
+
setTimeout(() => setPortsStatus(null), 2000);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handleSaveTitle = async () => {
|
|
28
|
+
const trimmed = boardTitle.trim();
|
|
29
|
+
if (!trimmed) return;
|
|
30
|
+
setTitleStatus('saving');
|
|
31
|
+
try {
|
|
32
|
+
await saveGeneralSettings({ boardTitle: trimmed });
|
|
33
|
+
setTitleStatus('saved');
|
|
34
|
+
onSaved();
|
|
35
|
+
setTimeout(() => setTitleStatus(null), 2000);
|
|
36
|
+
} catch {
|
|
37
|
+
setTitleStatus('error');
|
|
38
|
+
setTimeout(() => setTitleStatus(null), 2000);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="px-5 py-4 flex flex-col gap-6">
|
|
44
|
+
{/* General section */}
|
|
45
|
+
<div>
|
|
46
|
+
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">General</h3>
|
|
47
|
+
<div className="flex items-center gap-3">
|
|
48
|
+
<label className="text-sm text-slate-700 w-28 flex-shrink-0">Board title</label>
|
|
49
|
+
<input
|
|
50
|
+
type="text"
|
|
51
|
+
value={boardTitle}
|
|
52
|
+
onChange={(e) => setBoardTitle(e.target.value)}
|
|
53
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveTitle(); }}
|
|
54
|
+
className="flex-1 max-w-xs rounded-md border border-slate-300 px-2 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
55
|
+
/>
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={handleSaveTitle}
|
|
59
|
+
disabled={!boardTitle.trim() || titleStatus === 'saving'}
|
|
60
|
+
className="px-3 py-1.5 text-xs font-medium bg-slate-900 text-white rounded-md hover:bg-slate-700 transition-colors disabled:opacity-40"
|
|
61
|
+
>
|
|
62
|
+
{titleStatus === 'saving' ? (
|
|
63
|
+
<span className="inline-flex items-center gap-1">
|
|
64
|
+
<span className="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin" />
|
|
65
|
+
Saving
|
|
66
|
+
</span>
|
|
67
|
+
) : titleStatus === 'saved' ? '✓ Saved' : titleStatus === 'error' ? '✗ Error' : 'Save'}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Ports section */}
|
|
73
|
+
<div>
|
|
74
|
+
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-3">Server Ports</h3>
|
|
75
|
+
<div className="flex flex-col gap-3">
|
|
76
|
+
<div className="flex items-center gap-3">
|
|
77
|
+
<label className="text-sm text-slate-700 w-28 flex-shrink-0">Kanban board</label>
|
|
78
|
+
<input
|
|
79
|
+
type="number"
|
|
80
|
+
min="1024"
|
|
81
|
+
max="65535"
|
|
82
|
+
value={kanbanPort}
|
|
83
|
+
onChange={(e) => setKanbanPort(e.target.value)}
|
|
84
|
+
className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
<div className="flex items-center gap-3">
|
|
88
|
+
<label className="text-sm text-slate-700 w-28 flex-shrink-0">Documentation</label>
|
|
89
|
+
<input
|
|
90
|
+
type="number"
|
|
91
|
+
min="1024"
|
|
92
|
+
max="65535"
|
|
93
|
+
value={docsPort}
|
|
94
|
+
onChange={(e) => setDocsPort(e.target.value)}
|
|
95
|
+
className="w-24 rounded-md border border-slate-300 px-2 py-1.5 text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
<div className="flex items-center gap-3 pt-1">
|
|
99
|
+
<div className="w-28 flex-shrink-0" />
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={handleSavePorts}
|
|
103
|
+
disabled={portsStatus === 'saving'}
|
|
104
|
+
className="px-3 py-1.5 text-xs font-medium bg-slate-900 text-white rounded-md hover:bg-slate-700 transition-colors disabled:opacity-40"
|
|
105
|
+
>
|
|
106
|
+
{portsStatus === 'saving' ? (
|
|
107
|
+
<span className="inline-flex items-center gap-1">
|
|
108
|
+
<span className="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin" />
|
|
109
|
+
Saving
|
|
110
|
+
</span>
|
|
111
|
+
) : portsStatus === 'saved' ? '✓ Saved' : portsStatus === 'error' ? '✗ Error' : 'Save'}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<p className="text-xs text-slate-400 mt-3">
|
|
116
|
+
Changing ports requires restarting the servers (run <code className="font-mono bg-slate-100 px-1 rounded">/kanban</code> and <code className="font-mono bg-slate-100 px-1 rounded">/documentation</code>).
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { ApiKeysTab } from './ApiKeysTab';
|
|
4
|
+
import { CeremonyModelsTab } from './CeremonyModelsTab';
|
|
5
|
+
import { ServersTab } from './ServersTab';
|
|
6
|
+
import { ModelPricingTab } from './ModelPricingTab';
|
|
7
|
+
import { AgentsTab } from './AgentsTab';
|
|
8
|
+
import { CostThresholdsTab } from './CostThresholdsTab';
|
|
9
|
+
|
|
10
|
+
const TABS = [
|
|
11
|
+
{ id: 'api-keys', label: 'API Keys' },
|
|
12
|
+
{ id: 'ceremonies', label: 'Ceremony Models' },
|
|
13
|
+
{ id: 'agents', label: 'Agents' },
|
|
14
|
+
{ id: 'pricing', label: 'Model Pricing' },
|
|
15
|
+
{ id: 'cost-thresholds', label: 'Cost Limits' },
|
|
16
|
+
{ id: 'servers', label: 'Servers & Ports' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function SettingsModal({ settings, models, onClose, onSaved, initialTab }) {
|
|
20
|
+
const [activeTab, setActiveTab] = useState(initialTab || 'api-keys');
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
|
25
|
+
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
|
26
|
+
>
|
|
27
|
+
<div
|
|
28
|
+
className="w-full max-w-4xl bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
|
|
29
|
+
style={{ height: '85vh' }}
|
|
30
|
+
>
|
|
31
|
+
{/* Header */}
|
|
32
|
+
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100 flex-shrink-0">
|
|
33
|
+
<h2 className="text-base font-semibold text-slate-900">⚙ Project Settings</h2>
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
onClick={onClose}
|
|
37
|
+
className="text-slate-400 hover:text-slate-600 transition-colors ml-4"
|
|
38
|
+
aria-label="Close"
|
|
39
|
+
>
|
|
40
|
+
<X className="w-5 h-5" />
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{/* Tab bar */}
|
|
45
|
+
<div className="flex border-b border-slate-100 flex-shrink-0 px-5">
|
|
46
|
+
{TABS.map((tab) => (
|
|
47
|
+
<button
|
|
48
|
+
key={tab.id}
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => setActiveTab(tab.id)}
|
|
51
|
+
className={`px-3 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
|
52
|
+
activeTab === tab.id
|
|
53
|
+
? 'border-slate-900 text-slate-900'
|
|
54
|
+
: 'border-transparent text-slate-500 hover:text-slate-700'
|
|
55
|
+
}`}
|
|
56
|
+
>
|
|
57
|
+
{tab.label}
|
|
58
|
+
</button>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Tab content — scrollable */}
|
|
63
|
+
<div className="flex-1 overflow-y-auto">
|
|
64
|
+
{activeTab === 'api-keys' && (
|
|
65
|
+
<ApiKeysTab settings={settings} onSaved={onSaved} />
|
|
66
|
+
)}
|
|
67
|
+
{activeTab === 'ceremonies' && (
|
|
68
|
+
<CeremonyModelsTab settings={settings} models={models} onSaved={onSaved} />
|
|
69
|
+
)}
|
|
70
|
+
{activeTab === 'pricing' && (
|
|
71
|
+
<ModelPricingTab settings={settings} onSaved={onSaved} />
|
|
72
|
+
)}
|
|
73
|
+
{activeTab === 'cost-thresholds' && (
|
|
74
|
+
<CostThresholdsTab settings={settings} onSaved={onSaved} />
|
|
75
|
+
)}
|
|
76
|
+
{activeTab === 'servers' && (
|
|
77
|
+
<ServersTab settings={settings} onSaved={onSaved} />
|
|
78
|
+
)}
|
|
79
|
+
{activeTab === 'agents' && <AgentsTab />}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { X, BarChart2, DollarSign, ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
BarChart,
|
|
5
|
+
Bar,
|
|
6
|
+
XAxis,
|
|
7
|
+
YAxis,
|
|
8
|
+
Tooltip,
|
|
9
|
+
ResponsiveContainer,
|
|
10
|
+
} from 'recharts';
|
|
11
|
+
import { getCostHistory, getSettings } from '../../lib/api';
|
|
12
|
+
|
|
13
|
+
const RANGE_TABS = [
|
|
14
|
+
{ label: 'Today', value: 'today' },
|
|
15
|
+
{ label: '7 days', value: 7 },
|
|
16
|
+
{ label: '30 days', value: 30 },
|
|
17
|
+
{ label: '90 days', value: 90 },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function formatCostLabel(cost) {
|
|
21
|
+
if (cost === 0) return '$0.00';
|
|
22
|
+
if (cost < 0.01) return '< $0.01';
|
|
23
|
+
return `$${cost.toFixed(2)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatCostDetail(cost) {
|
|
27
|
+
if (cost === 0) return '$0.0000';
|
|
28
|
+
return `$${cost.toFixed(4)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTokens(n) {
|
|
32
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
33
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
34
|
+
return String(n);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatDateLabel(dateStr) {
|
|
38
|
+
const d = new Date(dateStr + 'T00:00:00');
|
|
39
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function formatCeremonyName(name) {
|
|
43
|
+
return name
|
|
44
|
+
.replace(/-/g, ' ')
|
|
45
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Strip parent prefix from stage name, e.g. "sponsor-call-validation" → "Validation" */
|
|
49
|
+
function formatStageName(name, parentName) {
|
|
50
|
+
if (name === parentName) return 'Documentation';
|
|
51
|
+
const prefix = `${parentName}-`;
|
|
52
|
+
if (name.startsWith(prefix)) return formatCeremonyName(name.slice(prefix.length));
|
|
53
|
+
return formatCeremonyName(name);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function CostModal({ onClose }) {
|
|
57
|
+
const [rangeMode, setRangeMode] = useState('today');
|
|
58
|
+
const [customFrom, setCustomFrom] = useState('');
|
|
59
|
+
const [customTo, setCustomTo] = useState('');
|
|
60
|
+
const [data, setData] = useState(null);
|
|
61
|
+
const [loading, setLoading] = useState(true);
|
|
62
|
+
const [expanded, setExpanded] = useState({});
|
|
63
|
+
const [oauthActive, setOauthActive] = useState(false);
|
|
64
|
+
|
|
65
|
+
// Check if OpenAI OAuth is active — if so, costs are not tracked for OpenAI calls
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
getSettings().then((s) => {
|
|
68
|
+
const openai = s?.apiKeys?.openai;
|
|
69
|
+
setOauthActive(openai?.authMode === 'oauth' && openai?.oauth?.connected === true);
|
|
70
|
+
}).catch(() => {});
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
// Fetch data when range changes
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
setLoading(true);
|
|
76
|
+
setData(null);
|
|
77
|
+
|
|
78
|
+
let rangeArg;
|
|
79
|
+
if (rangeMode === 'today') {
|
|
80
|
+
const today = new Date().toISOString().split('T')[0];
|
|
81
|
+
rangeArg = { from: today, to: today };
|
|
82
|
+
} else if (rangeMode === 'custom') {
|
|
83
|
+
if (!customFrom || !customTo) {
|
|
84
|
+
setLoading(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
rangeArg = { from: customFrom, to: customTo };
|
|
88
|
+
} else {
|
|
89
|
+
rangeArg = parseInt(rangeMode, 10);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getCostHistory(rangeArg)
|
|
93
|
+
.then((d) => {
|
|
94
|
+
setData(d);
|
|
95
|
+
setLoading(false);
|
|
96
|
+
// Auto-expand parents that have stages
|
|
97
|
+
const init = {};
|
|
98
|
+
(d.ceremonies || []).forEach((c) => {
|
|
99
|
+
if (c.stages && c.stages.length > 0) init[c.name] = true;
|
|
100
|
+
});
|
|
101
|
+
setExpanded(init);
|
|
102
|
+
})
|
|
103
|
+
.catch(() => { setData({ daily: [], ceremonies: [] }); setLoading(false); });
|
|
104
|
+
}, [rangeMode, customFrom, customTo]);
|
|
105
|
+
|
|
106
|
+
// Close on Escape
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
|
109
|
+
document.addEventListener('keydown', handler);
|
|
110
|
+
return () => document.removeEventListener('keydown', handler);
|
|
111
|
+
}, [onClose]);
|
|
112
|
+
|
|
113
|
+
const toggleExpanded = (name) => setExpanded((prev) => ({ ...prev, [name]: !prev[name] }));
|
|
114
|
+
|
|
115
|
+
// Totals come from parent nodes only — stages are already rolled up into them
|
|
116
|
+
const totalCost = data?.ceremonies.reduce((s, c) => s + c.cost, 0) ?? 0;
|
|
117
|
+
const totalTokens = data?.ceremonies.reduce((s, c) => s + c.tokens, 0) ?? 0;
|
|
118
|
+
const totalCalls = data?.ceremonies.reduce((s, c) => s + c.calls, 0) ?? 0;
|
|
119
|
+
const totalSaved = data?.ceremonies.reduce((s, c) => s + (c.saved ?? 0), 0) ?? 0;
|
|
120
|
+
const totalCached = data?.ceremonies.reduce((s, c) => s + (c.cached ?? 0), 0) ?? 0;
|
|
121
|
+
const hasData = data && (data.daily.length > 0 || data.ceremonies.length > 0);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="fixed inset-0 z-[65] flex items-center justify-center p-4">
|
|
125
|
+
{/* Backdrop */}
|
|
126
|
+
<div
|
|
127
|
+
className="absolute inset-0 bg-black/40"
|
|
128
|
+
onClick={onClose}
|
|
129
|
+
aria-hidden="true"
|
|
130
|
+
/>
|
|
131
|
+
|
|
132
|
+
{/* Panel */}
|
|
133
|
+
<div
|
|
134
|
+
className="relative bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col"
|
|
135
|
+
style={{ height: '90vh', maxHeight: '900px' }}
|
|
136
|
+
onClick={(e) => e.stopPropagation()}
|
|
137
|
+
>
|
|
138
|
+
{/* Header */}
|
|
139
|
+
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 flex-shrink-0">
|
|
140
|
+
<div className="flex items-center gap-2">
|
|
141
|
+
<DollarSign className="w-5 h-5 text-slate-500" />
|
|
142
|
+
<h2 className="text-lg font-semibold text-slate-900">LLM Cost Tracker</h2>
|
|
143
|
+
</div>
|
|
144
|
+
<button
|
|
145
|
+
onClick={onClose}
|
|
146
|
+
className="text-slate-400 hover:text-slate-600 transition-colors"
|
|
147
|
+
aria-label="Close"
|
|
148
|
+
>
|
|
149
|
+
<X className="w-5 h-5" />
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
{/* Scrollable body */}
|
|
154
|
+
<div className="overflow-y-auto flex-1 px-6 py-4 flex flex-col gap-5">
|
|
155
|
+
{/* Time range tabs — always 2 rows */}
|
|
156
|
+
<div className="flex flex-col gap-1.5">
|
|
157
|
+
{/* Row 1: preset buttons + Custom */}
|
|
158
|
+
<div className="flex items-center gap-2">
|
|
159
|
+
{RANGE_TABS.map((tab) => (
|
|
160
|
+
<button
|
|
161
|
+
key={tab.value}
|
|
162
|
+
onClick={() => setRangeMode(String(tab.value))}
|
|
163
|
+
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
164
|
+
rangeMode === String(tab.value)
|
|
165
|
+
? 'bg-blue-600 text-white'
|
|
166
|
+
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
167
|
+
}`}
|
|
168
|
+
>
|
|
169
|
+
{tab.label}
|
|
170
|
+
</button>
|
|
171
|
+
))}
|
|
172
|
+
<button
|
|
173
|
+
onClick={() => setRangeMode('custom')}
|
|
174
|
+
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
175
|
+
rangeMode === 'custom'
|
|
176
|
+
? 'bg-blue-600 text-white'
|
|
177
|
+
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
178
|
+
}`}
|
|
179
|
+
>
|
|
180
|
+
Custom
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Row 2: date inputs — always rendered to keep height constant */}
|
|
185
|
+
<div className={`flex items-center gap-2 ${rangeMode !== 'custom' ? 'invisible' : ''}`}>
|
|
186
|
+
<input
|
|
187
|
+
type="date"
|
|
188
|
+
value={customFrom}
|
|
189
|
+
onChange={(e) => setCustomFrom(e.target.value)}
|
|
190
|
+
className="text-sm border border-slate-300 rounded px-2 py-0.5 text-slate-700"
|
|
191
|
+
/>
|
|
192
|
+
<span className="text-slate-400 text-sm">to</span>
|
|
193
|
+
<input
|
|
194
|
+
type="date"
|
|
195
|
+
value={customTo}
|
|
196
|
+
onChange={(e) => setCustomTo(e.target.value)}
|
|
197
|
+
className="text-sm border border-slate-300 rounded px-2 py-0.5 text-slate-700"
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* OAuth notice */}
|
|
203
|
+
{oauthActive && (
|
|
204
|
+
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 text-xs text-blue-700 flex items-start gap-2">
|
|
205
|
+
<span className="flex-shrink-0 mt-0.5">ℹ️</span>
|
|
206
|
+
<span>
|
|
207
|
+
<strong>OpenAI OAuth active</strong> — API calls made via OAuth (ChatGPT subscription) are not billed per token.
|
|
208
|
+
No cost is recorded for OpenAI usage in this mode; token counts are still tracked for informational purposes.
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* Loading */}
|
|
214
|
+
{loading && (
|
|
215
|
+
<div className="flex-1 flex items-center justify-center">
|
|
216
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{/* No data */}
|
|
221
|
+
{!loading && !hasData && (
|
|
222
|
+
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-3">
|
|
223
|
+
<BarChart2 className="w-10 h-10" />
|
|
224
|
+
<p className="text-sm">No usage data for this period.</p>
|
|
225
|
+
<p className="text-xs text-slate-300">Run a ceremony to start tracking costs.</p>
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{/* Content */}
|
|
230
|
+
{!loading && hasData && (
|
|
231
|
+
<>
|
|
232
|
+
{/* Stat chips */}
|
|
233
|
+
<div className={`grid gap-3 ${totalSaved > 0 ? 'grid-cols-2' : 'grid-cols-3'}`}>
|
|
234
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
235
|
+
<p className="text-xs text-slate-500 mb-1">Total Cost</p>
|
|
236
|
+
<p className="text-xl font-bold text-slate-900">{formatCostLabel(totalCost)}</p>
|
|
237
|
+
<p className="text-xs text-slate-400 mt-0.5">this period</p>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
240
|
+
<p className="text-xs text-slate-500 mb-1">Total Tokens</p>
|
|
241
|
+
<p className="text-xl font-bold text-slate-900">{formatTokens(totalTokens)}</p>
|
|
242
|
+
{totalCached > 0 && (
|
|
243
|
+
<p className="text-xs text-blue-500 mt-0.5">{formatTokens(totalCached)} cached</p>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
<div className="bg-slate-50 rounded-lg p-3">
|
|
247
|
+
<p className="text-xs text-slate-500 mb-1">API Calls</p>
|
|
248
|
+
<p className="text-xl font-bold text-slate-900">{totalCalls.toLocaleString()}</p>
|
|
249
|
+
<p className="text-xs text-slate-400 mt-0.5">this period</p>
|
|
250
|
+
</div>
|
|
251
|
+
{totalSaved > 0 && (
|
|
252
|
+
<div className="bg-green-50 rounded-lg p-3">
|
|
253
|
+
<p className="text-xs text-green-600 mb-1">Cache Saved</p>
|
|
254
|
+
<p className="text-xl font-bold text-green-700">{formatCostLabel(totalSaved)}</p>
|
|
255
|
+
<p className="text-xs text-green-500 mt-0.5">vs. no cache</p>
|
|
256
|
+
</div>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
{/* Bar chart */}
|
|
261
|
+
{data.daily.length > 0 && (
|
|
262
|
+
<div>
|
|
263
|
+
<p className="text-xs font-medium text-slate-500 mb-2 uppercase tracking-wide">Daily Cost</p>
|
|
264
|
+
<ResponsiveContainer width="100%" height={180}>
|
|
265
|
+
<BarChart data={data.daily} margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
|
|
266
|
+
<XAxis
|
|
267
|
+
dataKey="date"
|
|
268
|
+
tickFormatter={formatDateLabel}
|
|
269
|
+
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
|
270
|
+
axisLine={false}
|
|
271
|
+
tickLine={false}
|
|
272
|
+
/>
|
|
273
|
+
<YAxis
|
|
274
|
+
tickFormatter={(v) => `$${v.toFixed(2)}`}
|
|
275
|
+
tick={{ fontSize: 11, fill: '#94a3b8' }}
|
|
276
|
+
width={56}
|
|
277
|
+
axisLine={false}
|
|
278
|
+
tickLine={false}
|
|
279
|
+
/>
|
|
280
|
+
<Tooltip
|
|
281
|
+
formatter={(v) => [`$${v.toFixed(4)}`, 'Cost']}
|
|
282
|
+
labelFormatter={(label) => formatDateLabel(label)}
|
|
283
|
+
contentStyle={{ fontSize: 12 }}
|
|
284
|
+
/>
|
|
285
|
+
<Bar dataKey="cost" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
|
286
|
+
</BarChart>
|
|
287
|
+
</ResponsiveContainer>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{/* Ceremony breakdown — hierarchical */}
|
|
292
|
+
{data.ceremonies.length > 0 && (
|
|
293
|
+
<div>
|
|
294
|
+
<p className="text-xs font-medium text-slate-500 mb-2 uppercase tracking-wide">By Ceremony</p>
|
|
295
|
+
<div className="overflow-x-auto">
|
|
296
|
+
<table className="w-full text-sm">
|
|
297
|
+
<thead>
|
|
298
|
+
<tr className="text-left text-xs text-slate-400 border-b border-slate-100">
|
|
299
|
+
<th className="pb-2 font-medium">Ceremony / Stage</th>
|
|
300
|
+
<th className="pb-2 font-medium text-right">Calls</th>
|
|
301
|
+
<th className="pb-2 font-medium text-right">Tokens</th>
|
|
302
|
+
<th className="pb-2 font-medium text-right">Cost</th>
|
|
303
|
+
<th className="pb-2 font-medium pl-4">Share</th>
|
|
304
|
+
</tr>
|
|
305
|
+
</thead>
|
|
306
|
+
<tbody>
|
|
307
|
+
{data.ceremonies.map((c) => {
|
|
308
|
+
const pct = totalCost > 0 ? (c.cost / totalCost) * 100 : 0;
|
|
309
|
+
const hasStages = c.stages && c.stages.length > 0;
|
|
310
|
+
const isOpen = expanded[c.name];
|
|
311
|
+
|
|
312
|
+
return [
|
|
313
|
+
/* Parent row */
|
|
314
|
+
<tr
|
|
315
|
+
key={c.name}
|
|
316
|
+
className={`border-b border-slate-100 ${hasStages ? 'cursor-pointer hover:bg-slate-50' : ''}`}
|
|
317
|
+
onClick={hasStages ? () => toggleExpanded(c.name) : undefined}
|
|
318
|
+
>
|
|
319
|
+
<td className="py-2 text-slate-800 font-semibold">
|
|
320
|
+
<div className="flex items-center gap-1.5">
|
|
321
|
+
{hasStages
|
|
322
|
+
? (isOpen
|
|
323
|
+
? <ChevronDown className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
|
324
|
+
: <ChevronRight className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />)
|
|
325
|
+
: <span className="w-3.5 flex-shrink-0" />
|
|
326
|
+
}
|
|
327
|
+
{formatCeremonyName(c.name)}
|
|
328
|
+
</div>
|
|
329
|
+
</td>
|
|
330
|
+
<td className="py-2 text-right text-slate-500">{c.calls}</td>
|
|
331
|
+
<td className="py-2 text-right text-slate-500">{formatTokens(c.tokens)}</td>
|
|
332
|
+
<td className="py-2 text-right text-slate-800 font-semibold">{formatCostDetail(c.cost)}</td>
|
|
333
|
+
<td className="py-2 pl-4">
|
|
334
|
+
<div className="flex items-center gap-2">
|
|
335
|
+
<div className="w-20 bg-slate-100 rounded-full h-1.5 flex-shrink-0">
|
|
336
|
+
<div
|
|
337
|
+
className="bg-blue-500 h-1.5 rounded-full"
|
|
338
|
+
style={{ width: `${pct}%` }}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
<span className="text-xs text-slate-400">{Math.round(pct)}%</span>
|
|
342
|
+
</div>
|
|
343
|
+
</td>
|
|
344
|
+
</tr>,
|
|
345
|
+
|
|
346
|
+
/* Stage rows — shown when expanded */
|
|
347
|
+
...(isOpen && hasStages ? c.stages.map((s) => {
|
|
348
|
+
const stagePct = c.cost > 0 ? (s.cost / c.cost) * 100 : 0;
|
|
349
|
+
return (
|
|
350
|
+
<tr key={`${c.name}/${s.name}`} className="border-b border-slate-50 bg-slate-50/50">
|
|
351
|
+
<td className="py-1.5 text-slate-500 pl-7">
|
|
352
|
+
{formatStageName(s.name, c.name)}
|
|
353
|
+
</td>
|
|
354
|
+
<td className="py-1.5 text-right text-slate-400 text-xs">{s.calls}</td>
|
|
355
|
+
<td className="py-1.5 text-right text-slate-400 text-xs">{formatTokens(s.tokens)}</td>
|
|
356
|
+
<td className="py-1.5 text-right text-slate-500 text-xs">{formatCostDetail(s.cost)}</td>
|
|
357
|
+
<td className="py-1.5 pl-4">
|
|
358
|
+
<div className="flex items-center gap-2">
|
|
359
|
+
<div className="w-20 bg-slate-100 rounded-full h-1 flex-shrink-0">
|
|
360
|
+
<div
|
|
361
|
+
className="bg-blue-300 h-1 rounded-full"
|
|
362
|
+
style={{ width: `${stagePct}%` }}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
<span className="text-[10px] text-slate-300">{Math.round(stagePct)}%</span>
|
|
366
|
+
</div>
|
|
367
|
+
</td>
|
|
368
|
+
</tr>
|
|
369
|
+
);
|
|
370
|
+
}) : []),
|
|
371
|
+
];
|
|
372
|
+
})}
|
|
373
|
+
</tbody>
|
|
374
|
+
</table>
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
);
|
|
384
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cn } from '../../lib/utils';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Badge Component (shadcn/ui style)
|
|
5
|
+
*/
|
|
6
|
+
export function Badge({ variant = 'default', className, children }) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
className={cn(
|
|
10
|
+
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold',
|
|
11
|
+
'transition-colors focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2',
|
|
12
|
+
{
|
|
13
|
+
'border-transparent bg-slate-900 text-slate-50 hover:bg-slate-900/80':
|
|
14
|
+
variant === 'default',
|
|
15
|
+
'border-transparent bg-slate-100 text-slate-900 hover:bg-slate-100/80':
|
|
16
|
+
variant === 'secondary',
|
|
17
|
+
'border-transparent bg-red-100 text-red-900 hover:bg-red-100/80':
|
|
18
|
+
variant === 'destructive',
|
|
19
|
+
'border-slate-200 text-slate-900': variant === 'outline',
|
|
20
|
+
},
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|