From 327a50bd414bae28011c45725aac19d630c667d1 Mon Sep 17 00:00:00 2001
From: Mina Choi
Date: Thu, 14 May 2026 17:06:33 +0900
Subject: [PATCH] =?UTF-8?q?feat:05/14=20UI/=EA=B8=B0=EB=8A=A5=20=EB=B3=80?=
=?UTF-8?q?=EA=B2=BD=EA=B1=B4=20=EC=BB=A4=EB=B0=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
package-lock.json | 236 ----------
package.json | 2 -
.../clinics/components/cards/AnalysisCard.tsx | 7 +-
.../clinics/data/mockClinicWorkspace.ts | 4 +-
.../plan/components/BrandAppliedPreview.tsx | 2 +-
.../components/PlanDownloadMenuButton.tsx | 64 +++
src/features/plan/data/mockPlan_banobagi.ts | 2 +-
src/features/plan/data/mockPlan_grand.ts | 2 +-
src/features/plan/data/mockPlan_irum.ts | 2 +-
src/features/plan/data/mockPlan_o2o.ts | 2 +-
src/features/plan/data/mockPlan_ts.ts | 2 +-
src/features/plan/data/mockPlan_wonjin.ts | 2 +-
src/features/plan/hooks/useExportPlanCSV.ts | 294 +++++++++++++
src/features/plan/hooks/useMarketingPlan.ts | 14 +-
src/features/plan/pages/GuestPlanPage.tsx | 20 +-
src/features/plan/pages/UserPlanPage.tsx | 25 +-
.../report/components/ClinicSnapshot.tsx | 52 +--
.../report/components/PdfDownloadButton.tsx | 51 ---
src/features/report/components/ReportNav.tsx | 17 +-
.../report/components/YouTubeAudit.tsx | 4 +-
.../report/hooks/useAnalysisPipeline.ts | 309 +++++++++++++
src/features/report/hooks/useReport.ts | 9 +-
.../report/hooks/useReportPageData.ts | 4 +
.../report/pages/AnalysisLoadingPage.tsx | 408 ++++--------------
src/features/report/pages/GuestReportPage.tsx | 14 +-
src/features/report/pages/UserReportPage.tsx | 7 +-
src/shared/api/api.ts | 5 +
src/shared/api/generated/channels/channels.ts | 120 ------
src/shared/api/generated/clinics/clinics.ts | 120 ++++++
src/shared/api/generated/reports/reports.ts | 6 +-
src/shared/api/model/analysisCreate.ts | 5 +-
src/shared/api/model/channelScore.ts | 13 +
src/shared/api/model/channelVerifyRequest.ts | 13 -
src/shared/api/model/channelVerifyResponse.ts | 13 -
.../model/channelVerifyResponseInstagram.ts | 9 -
...channelVerifyResponseInstagramAnyOfItem.ts | 8 -
.../api/model/channelVerifyResponseYoutube.ts | 9 -
.../channelVerifyResponseYoutubeAnyOf.ts | 8 -
src/shared/api/model/clinicCreate.ts | 5 -
src/shared/api/model/clinicCreateNameEn.ts | 8 -
src/shared/api/model/clinicResponse.ts | 22 +
...ube.ts => clinicResponseHospitalNameEn.ts} | 2 +-
src/shared/api/model/clinicResponseRawData.ts | 9 +
...agram.ts => clinicResponseRawDataAnyOf.ts} | 2 +-
...ddress.ts => clinicResponseRoadAddress.ts} | 2 +-
...lysisCreateUrl.ts => clinicResponseUrl.ts} | 2 +-
.../{clinicInfo.ts => conversionStrategy.ts} | 6 +-
...lysisCreateClinicId.ts => getReport200.ts} | 3 +-
src/shared/api/model/index.ts | 36 +-
src/shared/api/model/reportOutput.ts | 24 ++
src/shared/api/model/reportOutputFacebook.ts | 9 +
.../api/model/reportOutputGangnamUnni.ts | 9 +
src/shared/api/model/reportOutputInstagram.ts | 9 +
src/shared/api/model/reportOutputNaverBlog.ts | 9 +
src/shared/api/model/reportOutputYoutube.ts | 9 +
src/shared/api/model/reportResponse.ts | 30 --
.../model/reportResponseConversionStrategy.ts | 8 -
.../api/model/reportResponseFacebook.ts | 8 -
.../api/model/reportResponseGangnamUnni.ts | 8 -
.../api/model/reportResponseInstagram.ts | 8 -
.../api/model/reportResponseNaverBlog.ts | 8 -
.../api/model/reportResponseNaverPlace.ts | 8 -
src/shared/api/model/reportResponseYoutube.ts | 8 -
src/shared/constants/planSections.ts | 3 +-
src/shared/icons/AppIcon.tsx | 39 ++
src/shared/layouts/Footer.tsx | 6 +-
src/shared/layouts/Navbar.tsx | 4 +-
src/styles/custom.css | 6 +-
src/vite-env.d.ts | 10 +
69 files changed, 1175 insertions(+), 1029 deletions(-)
create mode 100644 src/features/plan/components/PlanDownloadMenuButton.tsx
create mode 100644 src/features/plan/hooks/useExportPlanCSV.ts
delete mode 100644 src/features/report/components/PdfDownloadButton.tsx
create mode 100644 src/features/report/hooks/useAnalysisPipeline.ts
delete mode 100644 src/shared/api/generated/channels/channels.ts
create mode 100644 src/shared/api/model/channelScore.ts
delete mode 100644 src/shared/api/model/channelVerifyRequest.ts
delete mode 100644 src/shared/api/model/channelVerifyResponse.ts
delete mode 100644 src/shared/api/model/channelVerifyResponseInstagram.ts
delete mode 100644 src/shared/api/model/channelVerifyResponseInstagramAnyOfItem.ts
delete mode 100644 src/shared/api/model/channelVerifyResponseYoutube.ts
delete mode 100644 src/shared/api/model/channelVerifyResponseYoutubeAnyOf.ts
delete mode 100644 src/shared/api/model/clinicCreateNameEn.ts
create mode 100644 src/shared/api/model/clinicResponse.ts
rename src/shared/api/model/{channelVerifyRequestYoutube.ts => clinicResponseHospitalNameEn.ts} (65%)
create mode 100644 src/shared/api/model/clinicResponseRawData.ts
rename src/shared/api/model/{channelVerifyRequestInstagram.ts => clinicResponseRawDataAnyOf.ts} (61%)
rename src/shared/api/model/{clinicCreateAddress.ts => clinicResponseRoadAddress.ts} (66%)
rename src/shared/api/model/{analysisCreateUrl.ts => clinicResponseUrl.ts} (70%)
rename src/shared/api/model/{clinicInfo.ts => conversionStrategy.ts} (59%)
rename src/shared/api/model/{analysisCreateClinicId.ts => getReport200.ts} (52%)
create mode 100644 src/shared/api/model/reportOutput.ts
create mode 100644 src/shared/api/model/reportOutputFacebook.ts
create mode 100644 src/shared/api/model/reportOutputGangnamUnni.ts
create mode 100644 src/shared/api/model/reportOutputInstagram.ts
create mode 100644 src/shared/api/model/reportOutputNaverBlog.ts
create mode 100644 src/shared/api/model/reportOutputYoutube.ts
delete mode 100644 src/shared/api/model/reportResponse.ts
delete mode 100644 src/shared/api/model/reportResponseConversionStrategy.ts
delete mode 100644 src/shared/api/model/reportResponseFacebook.ts
delete mode 100644 src/shared/api/model/reportResponseGangnamUnni.ts
delete mode 100644 src/shared/api/model/reportResponseInstagram.ts
delete mode 100644 src/shared/api/model/reportResponseNaverBlog.ts
delete mode 100644 src/shared/api/model/reportResponseNaverPlace.ts
delete mode 100644 src/shared/api/model/reportResponseYoutube.ts
create mode 100644 src/shared/icons/AppIcon.tsx
create mode 100644 src/vite-env.d.ts
diff --git a/package-lock.json b/package-lock.json
index f4df359..ccb0d19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,8 +12,6 @@
"@tanstack/react-query": "^5.59.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "html2canvas-pro": "^2.0.2",
- "jspdf": "^4.2.1",
"ky": "^1.7.5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
@@ -334,15 +332,6 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/runtime": {
- "version": "7.29.2",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
- "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -4070,19 +4059,6 @@
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/pako": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
- "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
- "license": "MIT"
- },
- "node_modules/@types/raf": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
- "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -4103,13 +4079,6 @@
"@types/react": "^19.2.0"
}
},
- "node_modules/@types/trusted-types": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
- "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
- "license": "MIT",
- "optional": true
- },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -4381,15 +4350,6 @@
"node": "18 || 20 || >=22"
}
},
- "node_modules/base64-arraybuffer": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
- "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6.0"
- }
- },
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -4541,26 +4501,6 @@
],
"license": "CC-BY-4.0"
},
- "node_modules/canvg": {
- "version": "3.0.11",
- "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
- "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@babel/runtime": "^7.12.5",
- "@types/raf": "^3.4.0",
- "core-js": "^3.8.3",
- "raf": "^3.4.1",
- "regenerator-runtime": "^0.13.7",
- "rgbcolor": "^1.0.1",
- "stackblur-canvas": "^2.0.0",
- "svg-pathdata": "^6.0.3"
- },
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4694,18 +4634,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/core-js": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
- "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/core-js"
- }
- },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4721,15 +4649,6 @@
"node": ">= 8"
}
},
- "node_modules/css-line-break": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
- "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -4884,16 +4803,6 @@
"node": ">=8"
}
},
- "node_modules/dompurify": {
- "version": "3.4.2",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
- "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==",
- "license": "(MPL-2.0 OR Apache-2.0)",
- "optional": true,
- "optionalDependencies": {
- "@types/trusted-types": "^2.0.7"
- }
- },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5267,17 +5176,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/fast-png": {
- "version": "6.4.0",
- "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
- "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
- "license": "MIT",
- "dependencies": {
- "@types/pako": "^2.0.3",
- "iobuffer": "^5.3.2",
- "pako": "^2.1.0"
- }
- },
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -5312,12 +5210,6 @@
"reusify": "^1.0.4"
}
},
- "node_modules/fflate": {
- "version": "0.8.2",
- "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
- "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
- "license": "MIT"
- },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -5736,33 +5628,6 @@
"node": ">= 0.4"
}
},
- "node_modules/html2canvas": {
- "version": "1.4.1",
- "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
- "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "css-line-break": "^2.1.0",
- "text-segmentation": "^1.0.3"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/html2canvas-pro": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz",
- "integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==",
- "license": "MIT",
- "dependencies": {
- "css-line-break": "^2.1.0",
- "text-segmentation": "^1.0.3"
- },
- "engines": {
- "node": ">=16.0.0"
- }
- },
"node_modules/http2-client": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz",
@@ -5823,12 +5688,6 @@
"node": ">= 0.4"
}
},
- "node_modules/iobuffer": {
- "version": "5.4.0",
- "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
- "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
- "license": "MIT"
- },
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -6374,23 +6233,6 @@
"node": "*"
}
},
- "node_modules/jspdf": {
- "version": "4.2.1",
- "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
- "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
- "license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.28.6",
- "fast-png": "^6.2.0",
- "fflate": "^0.8.1"
- },
- "optionalDependencies": {
- "canvg": "^3.0.11",
- "core-js": "^3.6.0",
- "dompurify": "^3.3.1",
- "html2canvas": "^1.0.0-rc.5"
- }
- },
"node_modules/ky": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/ky/-/ky-1.14.3.tgz",
@@ -7337,12 +7179,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/pako": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
- "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
- "license": "(MIT AND Zlib)"
- },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7373,13 +7209,6 @@
"node": ">=8"
}
},
- "node_modules/performance-now": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
- "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
- "license": "MIT",
- "optional": true
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7575,16 +7404,6 @@
}
}
},
- "node_modules/raf": {
- "version": "3.4.1",
- "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
- "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "performance-now": "^2.1.0"
- }
- },
"node_modules/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -7754,13 +7573,6 @@
"url": "https://github.com/Mermade/oas-kit?sponsor=1"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.13.11",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
- "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
- "license": "MIT",
- "optional": true
- },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -7813,16 +7625,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/rgbcolor": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
- "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
- "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
- "optional": true,
- "engines": {
- "node": ">= 0.8.15"
- }
- },
"node_modules/rollup": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
@@ -8211,16 +8013,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/stackblur-canvas": {
- "version": "2.7.0",
- "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
- "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=0.1.14"
- }
- },
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8355,16 +8147,6 @@
"node": ">=8"
}
},
- "node_modules/svg-pathdata": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
- "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
- "license": "MIT",
- "optional": true,
- "engines": {
- "node": ">=12.0.0"
- }
- },
"node_modules/swagger2openapi": {
"version": "7.0.8",
"resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz",
@@ -8434,15 +8216,6 @@
"url": "https://opencollective.com/webpack"
}
},
- "node_modules/text-segmentation": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
- "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
- "license": "MIT",
- "dependencies": {
- "utrie": "^1.0.2"
- }
- },
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -8849,15 +8622,6 @@
"node": ">= 4"
}
},
- "node_modules/utrie": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
- "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
- "license": "MIT",
- "dependencies": {
- "base64-arraybuffer": "^1.0.2"
- }
- },
"node_modules/validator": {
"version": "13.15.23",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
diff --git a/package.json b/package.json
index ac71575..5b74307 100644
--- a/package.json
+++ b/package.json
@@ -15,8 +15,6 @@
"@tanstack/react-query": "^5.59.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
- "html2canvas-pro": "^2.0.2",
- "jspdf": "^4.2.1",
"ky": "^1.7.5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
diff --git a/src/features/clinics/components/cards/AnalysisCard.tsx b/src/features/clinics/components/cards/AnalysisCard.tsx
index 65fdd65..310f73c 100644
--- a/src/features/clinics/components/cards/AnalysisCard.tsx
+++ b/src/features/clinics/components/cards/AnalysisCard.tsx
@@ -7,7 +7,8 @@
* - 분석 대상 플랫폼 칩(URL/핸들) 표시
*/
import { Link, useNavigate } from 'react-router';
-import { Calendar, ArrowUpRight, FileText, Sparkles } from 'lucide-react';
+import { Calendar, ArrowUpRight } from 'lucide-react';
+import { AppIcon } from '@/shared/icons/AppIcon';
import { PlatformChips } from '../PlatformChips';
import type { WorkspacePlan, WorkspaceRun } from '../../types/workspace';
@@ -106,7 +107,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy
onClick={stop}
className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-semibold text-white bg-gradient-to-r from-[#4F1DA1] to-[#021341] shadow-sm hover:opacity-90 transition-all"
>
-
+
리포트 보기
@@ -116,7 +117,7 @@ export function AnalysisCard({ clinicId, run, plan, highlighted = false }: Analy
onClick={stop}
className="inline-flex items-center justify-center gap-1.5 flex-1 md:flex-none px-4 py-2 rounded-full text-xs font-medium text-brand-purple bg-brand-tint-purple/60 border border-brand-tint-lavender hover:bg-brand-tint-purple transition-colors"
>
-
+
기획 보기
diff --git a/src/features/clinics/data/mockClinicWorkspace.ts b/src/features/clinics/data/mockClinicWorkspace.ts
index cbeb628..cbc1d8c 100644
--- a/src/features/clinics/data/mockClinicWorkspace.ts
+++ b/src/features/clinics/data/mockClinicWorkspace.ts
@@ -8,8 +8,8 @@ import type { WorkspaceData } from '../types/workspace';
export const mockWorkspace: WorkspaceData = {
clinic: {
- clinicId: 'view-gangnam',
- name: '뷰성형외과',
+ clinicId: 'view-clinic',
+ name: '뷰성형외과의원',
nameEn: 'VIEW Plastic Surgery',
location: '서울 강남구 신사동',
brandColor: '#4F1DA1',
diff --git a/src/features/plan/components/BrandAppliedPreview.tsx b/src/features/plan/components/BrandAppliedPreview.tsx
index 03eb5dd..92901d2 100644
--- a/src/features/plan/components/BrandAppliedPreview.tsx
+++ b/src/features/plan/components/BrandAppliedPreview.tsx
@@ -90,7 +90,7 @@ export function BrandAppliedPreview({
등록된 컬러·타이포가 실제 콘텐츠에 적용됐을 때의 모습입니다.
-
+
{/* ── Instagram Post Mockup ─────────────────────────── */}
+
+ {isExporting ? (
+ <>
+
+ 생성 중...
+ >
+ ) : (
+ <>
+
+ 다운로드
+
+ >
+ )}
+
+
+ exportPDF(filename)}>
+
+ PDF로 저장
+
+ exportPlanCSV(filename, plan)}>
+
+ CSV로 저장
+
+
+
+ );
+}
diff --git a/src/features/plan/data/mockPlan_banobagi.ts b/src/features/plan/data/mockPlan_banobagi.ts
index 18e13a6..8a4ea15 100644
--- a/src/features/plan/data/mockPlan_banobagi.ts
+++ b/src/features/plan/data/mockPlan_banobagi.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * 바노바기성형외과 — 데모 마케팅 플랜
+ * 바노바기성형외과 — 데모 마케팅 기획
*
* 리포트 근거 (`mockReport_banobagi.ts` 2026-04-14 실측):
* - YouTube @banobagips: 13K 구독자, 925 영상 (최근 6개월 업로드 저조)
diff --git a/src/features/plan/data/mockPlan_grand.ts b/src/features/plan/data/mockPlan_grand.ts
index 9743438..5a39bdb 100644
--- a/src/features/plan/data/mockPlan_grand.ts
+++ b/src/features/plan/data/mockPlan_grand.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * 그랜드성형외과 — 데모 마케팅 플랜
+ * 그랜드성형외과 — 데모 마케팅 기획
*
* 리포트 근거 (`mockReport_grand.ts` 2026-04-14 실측):
* - YouTube @grandsurgery_QnA: 2.37K 구독자, 332 영상, 업로드 ~0/week (사실상 중단)
diff --git a/src/features/plan/data/mockPlan_irum.ts b/src/features/plan/data/mockPlan_irum.ts
index 5c85fcc..807c669 100644
--- a/src/features/plan/data/mockPlan_irum.ts
+++ b/src/features/plan/data/mockPlan_irum.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 플랜
+ * 이룸성형외과 (서울아이 / Seoul i Plastic Surgery) — 맞춤 마케팅 기획
*
* 리포트 근거 (mockReport_irum.ts, 2026-04-14 실측):
* - YouTube @SEOULiPS.: 구독자 322명, 영상 155개 (성장 완전 정체)
diff --git a/src/features/plan/data/mockPlan_o2o.ts b/src/features/plan/data/mockPlan_o2o.ts
index 0cf8d75..416f5b5 100644
--- a/src/features/plan/data/mockPlan_o2o.ts
+++ b/src/features/plan/data/mockPlan_o2o.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * O2O Clinic — 가상 데모 마케팅 플랜 (외부 노출 가능)
+ * O2O Clinic — 가상 데모 마케팅 기획 (외부 노출 가능)
*
* 의료광고법·개인정보 우려 없이 자유롭게 광고/마케팅 자료로 사용 가능한
* INFINITH 솔루션 데모 자산. 6개 실제 병원 분석 패턴의 베스트 프랙티스를
diff --git a/src/features/plan/data/mockPlan_ts.ts b/src/features/plan/data/mockPlan_ts.ts
index 7230473..435e992 100644
--- a/src/features/plan/data/mockPlan_ts.ts
+++ b/src/features/plan/data/mockPlan_ts.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * 티에스성형외과 — 데모 마케팅 플랜
+ * 티에스성형외과 — 데모 마케팅 기획
*
* 리포트 근거 (`mockReport_ts.ts` 2026-04-14 실측):
* - YouTube 티에스TV @TV-jm9dy: 8K 구독자, 715 영상, 주 1~2회 업로드
diff --git a/src/features/plan/data/mockPlan_wonjin.ts b/src/features/plan/data/mockPlan_wonjin.ts
index 3cec655..bf52b85 100644
--- a/src/features/plan/data/mockPlan_wonjin.ts
+++ b/src/features/plan/data/mockPlan_wonjin.ts
@@ -1,7 +1,7 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
- * 원진성형외과 — 데모 마케팅 플랜
+ * 원진성형외과 — 데모 마케팅 기획
*
* 리포트 근거 (`mockReport_wonjin.ts` 2026-04-14 실측):
* - YouTube @wjwonjin: 14.1K 구독자, 350 영상, 주 3~5회 (Shorts 활발)
diff --git a/src/features/plan/hooks/useExportPlanCSV.ts b/src/features/plan/hooks/useExportPlanCSV.ts
new file mode 100644
index 0000000..849fe68
--- /dev/null
+++ b/src/features/plan/hooks/useExportPlanCSV.ts
@@ -0,0 +1,294 @@
+import { useCallback } from 'react';
+import type { MarketingPlan } from '@/features/plan/types/plan';
+
+/**
+ * 마케팅 기획의 표 데이터 전체를 단일 CSV로 내보내는 훅.
+ * 섹션마다 `=== Section Title ===` 헤더로 구분.
+ * Excel/Numbers에서 한글 안 깨지도록 UTF-8 BOM 포함.
+ */
+
+type Cell = string | number | boolean | null | undefined;
+type Row = Cell[];
+type Section = { title: string; rows: Row[] };
+
+function escapeCell(value: Cell): string {
+ const s = String(value ?? '');
+ if (/[",\n\r]/.test(s)) {
+ return `"${s.replace(/"/g, '""')}"`;
+ }
+ return s;
+}
+
+function buildPlanCsv(plan: MarketingPlan): string {
+ const sections: Section[] = [];
+
+ // ─── 메타 ───
+ sections.push({
+ title: 'Plan Metadata',
+ rows: [
+ ['Field', 'Value'],
+ ['Clinic Name', plan.clinicName],
+ ['Clinic Name (EN)', plan.clinicNameEn],
+ ['Target URL', plan.targetUrl],
+ ['Created At', plan.createdAt],
+ ['Plan ID', plan.id],
+ ['Source Report ID', plan.reportId],
+ ],
+ });
+
+ // ─── Brand Guide ───
+ const bg = plan.brandGuide;
+ if (bg?.colors?.length) {
+ sections.push({
+ title: 'Brand Guide: Colors',
+ rows: [
+ ['Name', 'Hex', 'Usage'],
+ ...bg.colors.map((c) => [c.name, c.hex, c.usage]),
+ ],
+ });
+ }
+ if (bg?.fonts?.length) {
+ sections.push({
+ title: 'Brand Guide: Fonts',
+ rows: [
+ ['Family', 'Weight', 'Usage', 'Sample Text'],
+ ...bg.fonts.map((f) => [f.family, f.weight, f.usage, f.sampleText]),
+ ],
+ });
+ }
+ if (bg?.logoRules?.length) {
+ sections.push({
+ title: 'Brand Guide: Logo Rules',
+ rows: [
+ ['Rule', 'Description', 'Correct'],
+ ...bg.logoRules.map((r) => [r.rule, r.description, r.correct ? 'Y' : 'N']),
+ ],
+ });
+ }
+ if (bg?.toneOfVoice) {
+ const t = bg.toneOfVoice;
+ sections.push({
+ title: 'Brand Guide: Tone of Voice',
+ rows: [
+ ['Field', 'Value'],
+ ['Personality', (t.personality ?? []).join(' / ')],
+ ['Communication Style', t.communicationStyle],
+ ['Do Examples', (t.doExamples ?? []).join(' | ')],
+ ['Dont Examples', (t.dontExamples ?? []).join(' | ')],
+ ],
+ });
+ }
+ if (bg?.channelBranding?.length) {
+ sections.push({
+ title: 'Brand Guide: Channel Branding',
+ rows: [
+ ['Channel', 'Profile Photo', 'Banner Spec', 'Bio Template', 'Current Status'],
+ ...bg.channelBranding.map((c) => [
+ c.channel,
+ c.profilePhoto,
+ c.bannerSpec,
+ c.bioTemplate,
+ c.currentStatus,
+ ]),
+ ],
+ });
+ }
+ if (bg?.brandInconsistencies?.length) {
+ const rows: Row[] = [['Field', 'Channel', 'Value', 'Correct', 'Impact', 'Recommendation']];
+ bg.brandInconsistencies.forEach((b) => {
+ b.values.forEach((v, i) => {
+ rows.push([
+ i === 0 ? b.field : '',
+ v.channel,
+ v.value,
+ v.isCorrect ? 'Y' : 'N',
+ i === 0 ? b.impact : '',
+ i === 0 ? b.recommendation : '',
+ ]);
+ });
+ });
+ sections.push({ title: 'Brand Guide: Brand Inconsistencies', rows });
+ }
+
+ // ─── Channel Strategies ───
+ if (plan.channelStrategies?.length) {
+ sections.push({
+ title: 'Channel Strategies',
+ rows: [
+ ['Channel', 'Current Status', 'Target Goal', 'Content Types', 'Posting Frequency', 'Tone', 'Priority', 'Journey Stage'],
+ ...plan.channelStrategies.map((s) => [
+ s.channelName,
+ s.currentStatus,
+ s.targetGoal,
+ (s.contentTypes ?? []).join(' | '),
+ s.postingFrequency,
+ s.tone,
+ s.priority,
+ s.customerJourneyStage ?? '',
+ ]),
+ ],
+ });
+ }
+
+ // ─── Content Strategy ───
+ const cs = plan.contentStrategy;
+ if (cs?.pillars?.length) {
+ sections.push({
+ title: 'Content Pillars',
+ rows: [
+ ['Title', 'Description', 'Related USP', 'Example Topics'],
+ ...cs.pillars.map((p) => [p.title, p.description, p.relatedUSP, (p.exampleTopics ?? []).join(' | ')]),
+ ],
+ });
+ }
+ if (cs?.typeMatrix?.length) {
+ sections.push({
+ title: 'Content Type Matrix',
+ rows: [
+ ['Format', 'Channels', 'Frequency', 'Purpose'],
+ ...cs.typeMatrix.map((t) => [t.format, (t.channels ?? []).join(' | '), t.frequency, t.purpose]),
+ ],
+ });
+ }
+ if (cs?.workflow?.length) {
+ sections.push({
+ title: 'Content Workflow',
+ rows: [
+ ['Step', 'Name', 'Description', 'Owner', 'Duration'],
+ ...cs.workflow.map((w) => [w.step, w.name, w.description, w.owner, w.duration]),
+ ],
+ });
+ }
+ if (cs?.repurposingOutputs?.length) {
+ sections.push({
+ title: `Repurposing Outputs (source: ${cs.repurposingSource ?? ''})`,
+ rows: [
+ ['Format', 'Channel', 'Description'],
+ ...cs.repurposingOutputs.map((r) => [r.format, r.channel, r.description]),
+ ],
+ });
+ }
+
+ // ─── Calendar (주차 → 일별 항목으로 풀어 펼침) ───
+ const cal = plan.calendar;
+ if (cal?.weeks?.length) {
+ const rows: Row[] = [['Week', 'Day of Week', 'Channel', 'Content Type', 'Title', 'Description', 'Pillar', 'Status']];
+ cal.weeks.forEach((w) => {
+ w.entries.forEach((e, i) => {
+ rows.push([
+ i === 0 ? w.label : '',
+ e.dayOfWeek,
+ e.channel,
+ e.contentType,
+ e.title,
+ e.description ?? '',
+ e.pillar ?? '',
+ e.status ?? '',
+ ]);
+ });
+ });
+ sections.push({ title: 'Content Calendar', rows });
+ }
+ if (cal?.monthlySummary?.length) {
+ sections.push({
+ title: 'Monthly Content Summary',
+ rows: [
+ ['Type', 'Label', 'Count'],
+ ...cal.monthlySummary.map((s) => [s.type, s.label, s.count]),
+ ],
+ });
+ }
+
+ // ─── Asset Collection ───
+ const ac = plan.assetCollection;
+ if (ac?.assets?.length) {
+ sections.push({
+ title: 'Asset Collection',
+ rows: [
+ ['Source', 'Type', 'Title', 'Description', 'Status', 'Repurposing Suggestions'],
+ ...ac.assets.map((a) => [
+ a.sourceLabel,
+ a.type,
+ a.title,
+ a.description,
+ a.status,
+ (a.repurposingSuggestions ?? []).join(' | '),
+ ]),
+ ],
+ });
+ }
+ if (ac?.youtubeRepurpose?.length) {
+ sections.push({
+ title: 'YouTube Repurpose Sources',
+ rows: [
+ ['Title', 'Views', 'Type', 'Repurpose As'],
+ ...ac.youtubeRepurpose.map((y) => [y.title, y.views, y.type, (y.repurposeAs ?? []).join(' | ')]),
+ ],
+ });
+ }
+
+ // ─── Repurposing Proposals ───
+ if (plan.repurposingProposals?.length) {
+ sections.push({
+ title: 'Repurposing Proposals',
+ rows: [
+ ['Source Video', 'Source Views', 'Source Type', 'Output Count', 'Priority', 'Estimated Effort'],
+ ...plan.repurposingProposals.map((p) => [
+ p.sourceVideo.title,
+ p.sourceVideo.views,
+ p.sourceVideo.type,
+ p.outputs.length,
+ p.priority,
+ p.estimatedEffort,
+ ]),
+ ],
+ });
+ }
+
+ // ─── Workflow Items ───
+ if (plan.workflow?.items?.length) {
+ sections.push({
+ title: 'Workflow Items',
+ rows: [
+ ['Title', 'Content Type', 'Channel', 'Stage', 'Scheduled Date', 'User Notes'],
+ ...plan.workflow.items.map((w) => [
+ w.title,
+ w.contentType,
+ w.channel,
+ w.stage,
+ w.scheduledDate ?? '',
+ w.userNotes ?? '',
+ ]),
+ ],
+ });
+ }
+
+ // ─── 조립 ───
+ const lines: string[] = [];
+ sections.forEach((sec, idx) => {
+ if (idx > 0) lines.push('');
+ lines.push(`=== ${sec.title} ===`);
+ sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
+ });
+
+ return lines.join('\r\n');
+}
+
+export function useExportPlanCSV() {
+ const exportPlanCSV = useCallback((filename: string, plan: MarketingPlan) => {
+ const csv = '' + buildPlanCsv(plan); // UTF-8 BOM
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${filename}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ URL.revokeObjectURL(url);
+ }, []);
+
+ return { exportPlanCSV };
+}
diff --git a/src/features/plan/hooks/useMarketingPlan.ts b/src/features/plan/hooks/useMarketingPlan.ts
index 13837c5..7d2df6b 100644
--- a/src/features/plan/hooks/useMarketingPlan.ts
+++ b/src/features/plan/hooks/useMarketingPlan.ts
@@ -30,6 +30,8 @@ interface UseMarketingPlanResult {
data: MarketingPlan | null;
isLoading: boolean;
error: string | null;
+ /** DB row 의 clinic_id — 게스트 페이지에서 워크스페이스 점프 버튼 노출에 사용 */
+ clinicId: string | null;
}
interface LocationState {
@@ -96,6 +98,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ const [clinicId, setClinicId] = useState(null);
const location = useLocation();
useEffect(() => {
@@ -106,17 +109,20 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
}
const state = location.state as LocationState | undefined;
+ const stateClinicId = state?.clinicId ?? null;
async function loadPlan() {
try {
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
if (id === 'demo') {
setData(mockPlan);
+ setClinicId(null);
setIsLoading(false);
return;
}
if (id && id in DEMO_PLANS) {
setData(DEMO_PLANS[id]);
+ setClinicId(null);
setIsLoading(false);
return;
}
@@ -129,13 +135,15 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
try {
const planRes = await getPlan(id!);
if (planRes.status === 200 && planRes.data) {
+ const planRow = planRes.data as unknown as Record;
const plan = buildPlanFromContentPlans(
- planRes.data as unknown as Record,
+ planRow,
clinicName,
clinicNameEn,
targetUrl,
);
setData(plan);
+ setClinicId((planRow.clinic_id as string) || stateClinicId);
setIsLoading(false);
return;
}
@@ -153,6 +161,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
});
setData(plan);
+ setClinicId(stateClinicId);
setIsLoading(false);
return;
}
@@ -168,6 +177,7 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
created_at: (reportRow.created_at as string) || new Date().toISOString(),
});
setData(plan);
+ setClinicId((reportRow.clinic_id as string) || stateClinicId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
} finally {
@@ -178,5 +188,5 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
loadPlan();
}, [id, location.state]);
- return { data, isLoading, error };
+ return { data, isLoading, error, clinicId };
}
diff --git a/src/features/plan/pages/GuestPlanPage.tsx b/src/features/plan/pages/GuestPlanPage.tsx
index 193f6d3..4ba38e3 100644
--- a/src/features/plan/pages/GuestPlanPage.tsx
+++ b/src/features/plan/pages/GuestPlanPage.tsx
@@ -7,13 +7,13 @@
*/
import { useEffect } from 'react';
import { Link, useParams, useLocation } from 'react-router';
-import { ArrowRight, FileSearch } from 'lucide-react';
+import { ArrowRight, ArrowLeft } from 'lucide-react';
+import { AppIcon } from '@/shared/icons/AppIcon';
import { useMarketingPlan } from '../hooks/useMarketingPlan';
import { ReportNav } from '@/features/report/components/ReportNav';
-import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton';
+import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton';
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
import PlanBody from '../components/PlanBody';
-import PlanCTA from '../components/PlanCTA';
export default function GuestPlanPage() {
const { id } = useParams<{ id: string }>();
@@ -62,14 +62,23 @@ export default function GuestPlanPage() {
+
+ 클리닉으로
+
+ }
rightSlot={
-
+
-
+
분석 리포트 보기
@@ -77,7 +86,6 @@ export default function GuestPlanPage() {
}
/>
-
);
}
diff --git a/src/features/plan/pages/UserPlanPage.tsx b/src/features/plan/pages/UserPlanPage.tsx
index cef6fff..f03851e 100644
--- a/src/features/plan/pages/UserPlanPage.tsx
+++ b/src/features/plan/pages/UserPlanPage.tsx
@@ -3,24 +3,28 @@
*
* 계약된 병원 유저가 워크스페이스에서 운영하는 마케팅 기획 화면.
* GuestPlanPage 의 본문 + 워크스페이스 액션바 + 인터랙티브 섹션
- * (MyAssetUpload / StrategyAdjustmentSection / WorkflowTracker).
+ * (MyAssetUpload / WorkflowTracker).
+ *
+ * NOTE: StrategyAdjustmentSection(성과 기반 전략 조정) 은 성과 데이터 파이프라인 의존 →
+ * 본 차수 미포함, 후속 모듈로 이관. 아래 import/render 주석 처리.
*/
import { useEffect } from 'react';
import { Link, useParams, useLocation } from 'react-router';
-import { ArrowLeft, FileSearch } from 'lucide-react';
+import { ArrowLeft } from 'lucide-react';
+import { AppIcon } from '@/shared/icons/AppIcon';
import { useMarketingPlan } from '../hooks/useMarketingPlan';
import { ReportNav } from '@/features/report/components/ReportNav';
-import { PdfDownloadButton } from '@/features/report/components/PdfDownloadButton';
+import { PlanDownloadMenuButton } from '../components/PlanDownloadMenuButton';
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
import PlanBody from '../components/PlanBody';
import MyAssetUpload from '../components/MyAssetUpload';
-import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection';
+// import StrategyAdjustmentSection from '../components/StrategyAdjustmentSection';
import WorkflowTracker from '../components/WorkflowTracker';
export default function UserPlanPage() {
const { clinicId, id } = useParams<{ clinicId: string; id: string }>();
const location = useLocation();
- const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
+ // const stateClinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
const { data, isLoading, error } = useMarketingPlan(id);
useEffect(() => {
@@ -77,18 +81,18 @@ export default function UserPlanPage() {
className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-primary-900 transition-colors"
>
- 워크스페이스로
+ 클리닉으로
}
rightSlot={
-
+
{reportTargetId && (
-
+
기반 리포트
)}
@@ -109,9 +113,10 @@ export default function UserPlanPage() {
-
+ {/* 성과 기반 전략 조정 — 본 차수 미포함, 후속 모듈 이관 */}
+ {/*
-
+
*/}
);
}
diff --git a/src/features/report/components/ClinicSnapshot.tsx b/src/features/report/components/ClinicSnapshot.tsx
index f1641f1..d765fab 100644
--- a/src/features/report/components/ClinicSnapshot.tsx
+++ b/src/features/report/components/ClinicSnapshot.tsx
@@ -1,5 +1,5 @@
import { motion } from 'motion/react';
-import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
+import { Calendar, Users, MapPin, Phone, BadgeCheck, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report';
@@ -32,54 +32,10 @@ const infoFields = (data: ClinicSnapshotType): InfoField[] => [
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
- const isVerified = data.source === 'registry';
return (
- {/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
-
- {/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
- {isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
-
- {data.registryData?.gangnamUnniUrl && (
-
- 강남언니
-
- )}
- {data.registryData?.naverPlaceUrl && (
-
- 네이버 플레이스
-
- )}
- {data.registryData?.googleMapsUrl && (
-
- Google Maps
-
- )}
-
- )}
-
-
+
{fields.map((field, i) => {
const Icon = field.icon;
return (
@@ -127,7 +83,7 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
transition={{ duration: 0.5, delay: 0.3 }}
>
{data.leadDoctor.name}
@@ -173,7 +129,7 @@ export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
key={cert}
className="inline-flex items-center gap-1.5 rounded-full bg-brand-tint-purple border border-brand-tint-lavender px-3 py-1 text-sm font-medium text-brand-purple-muted"
>
-
+
{cert}
))}
diff --git a/src/features/report/components/PdfDownloadButton.tsx b/src/features/report/components/PdfDownloadButton.tsx
deleted file mode 100644
index 2cbb227..0000000
--- a/src/features/report/components/PdfDownloadButton.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-/**
- * PdfDownloadButton — 리포트/플랜 PDF 다운로드 버튼.
- *
- * 브라우저 네이티브 인쇄 다이얼로그를 띄워 "PDF로 저장" 흐름을 사용합니다.
- * `@media print` 규칙(`src/styles/custom.css`)이 레이아웃·색상·페이지 분할을 담당하며,
- * `data-no-print`/`data-report-nav`/`data-plan-nav`/`data-cta-card`/`nav` 요소는
- * 인쇄에서 자동 제외됩니다.
- */
-import { Download, Loader2 } from 'lucide-react';
-import { useExportPDF } from '@/features/report/hooks/useExportPDF';
-
-interface PdfDownloadButtonProps {
- /** PDF 파일명. 자동으로 .pdf 확장자 붙음. 'Plan' 포함 시 푸터 라벨도 변경 */
- filename: string;
- /** 버튼 표시 라벨. 기본: PDF */
- label?: string;
- className?: string;
-}
-
-export function PdfDownloadButton({
- filename,
- label = 'PDF',
- className,
-}: PdfDownloadButtonProps) {
- const { exportPDF, isExporting } = useExportPDF();
-
- return (
-
- );
-}
diff --git a/src/features/report/components/ReportNav.tsx b/src/features/report/components/ReportNav.tsx
index d7a65d4..fd3d009 100644
--- a/src/features/report/components/ReportNav.tsx
+++ b/src/features/report/components/ReportNav.tsx
@@ -39,13 +39,14 @@ export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) {
useEffect(() => {
const activeTab = tabRefs.current.get(activeId);
- if (activeTab && navRef.current) {
- activeTab.scrollIntoView({
- behavior: 'smooth',
- block: 'nearest',
- inline: 'center',
- });
- }
+ const navEl = navRef.current;
+ if (!activeTab || !navEl) return;
+
+ // scrollIntoView 는 sticky 컨테이너에서 세로 스크롤까지 트리거하는 부작용이 있어
+ // (초기 마운트 시 sticky 위치 계산 전 탭의 실제 y 로 페이지를 끌어내림 → 첫 섹션 점프)
+ // 가로 스크롤만 직접 제어합니다.
+ const targetLeft = activeTab.offsetLeft - (navEl.clientWidth - activeTab.offsetWidth) / 2;
+ navEl.scrollTo({ left: targetLeft, behavior: 'smooth' });
}, [activeId]);
const handleClick = (id: string) => {
@@ -61,7 +62,7 @@ export function ReportNav({ sections, leftSlot, rightSlot }: ReportNavProps) {
-
㈜에이아이오투오
+
㈜에이아이오투오
사업자 등록번호 : 620-87-00810 | 대표 : 안성민
본사 : 41593 대구광역시 북구 옥산로 111, 5층 유니콘랩 대구 A05호
연구소 : 13453 경기 성남시 수정구 금토로 32 (금토동) (주)KT 판교빌딩 504호~505호 (East)
전화 : 070-4260-8310 | 010-2755-6463
이메일 : o2oteam@o2o.kr
-
+
Copyright ⓒ O2O Inc. All rights reserved
-
+
);
diff --git a/src/shared/layouts/Navbar.tsx b/src/shared/layouts/Navbar.tsx
index 6512fc9..6949fa3 100644
--- a/src/shared/layouts/Navbar.tsx
+++ b/src/shared/layouts/Navbar.tsx
@@ -37,7 +37,7 @@ export default function Navbar() {
{/* 가운데 메뉴 — 랜딩 페이지 섹션 순서 그대로 */}