feat(youtube-tab): RevenueTab 수익 추적 서브탭
This commit is contained in:
@@ -1,3 +1,276 @@
|
|||||||
|
// src/pages/music/components/RevenueTab.jsx
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
getRevenueDashboard, getRevenueRecords,
|
||||||
|
addRevenueRecord, updateRevenueRecord, deleteRevenueRecord,
|
||||||
|
} from '../../../api';
|
||||||
|
|
||||||
|
const COUNTRIES = ['BR', 'US', 'ID', 'MX', 'KR'];
|
||||||
|
const currentMonth = () => new Date().toISOString().slice(0, 7);
|
||||||
|
|
||||||
export default function RevenueTab() {
|
export default function RevenueTab() {
|
||||||
return null;
|
const [dashboard, setDashboard] = useState(null);
|
||||||
|
const [records, setRecords] = useState([]);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
yt_video_id: '', record_month: currentMonth(),
|
||||||
|
revenue_usd: '', views: '', country: 'BR',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState(null);
|
||||||
|
const [editForm, setEditForm] = useState({});
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
const [dash, recs] = await Promise.all([
|
||||||
|
getRevenueDashboard().catch(() => null),
|
||||||
|
getRevenueRecords().catch(() => []),
|
||||||
|
]);
|
||||||
|
setDashboard(dash);
|
||||||
|
setRecords(Array.isArray(recs) ? recs : recs.records ?? []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadAll(); }, []);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!form.yt_video_id || !form.revenue_usd || !form.views) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await addRevenueRecord({
|
||||||
|
yt_video_id: form.yt_video_id,
|
||||||
|
record_month: form.record_month,
|
||||||
|
revenue_usd: parseFloat(form.revenue_usd),
|
||||||
|
views: parseInt(form.views, 10),
|
||||||
|
country: form.country,
|
||||||
|
});
|
||||||
|
setForm({ yt_video_id: '', record_month: currentMonth(), revenue_usd: '', views: '', country: 'BR' });
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('addRevenueRecord:', e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
try {
|
||||||
|
await updateRevenueRecord(editingId, {
|
||||||
|
revenue_usd: parseFloat(editForm.revenue_usd),
|
||||||
|
views: parseInt(editForm.views, 10),
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('updateRevenueRecord:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!window.confirm('이 기록을 삭제할까요?')) return;
|
||||||
|
try {
|
||||||
|
await deleteRevenueRecord(id);
|
||||||
|
await loadAll();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('deleteRevenueRecord:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 영상별 RPM 상위 5개 (bar chart 용)
|
||||||
|
const chartData = records
|
||||||
|
.filter(r => r.views > 0)
|
||||||
|
.map(r => ({
|
||||||
|
label: r.yt_video_id,
|
||||||
|
rpm: (r.revenue_usd / r.views) * 1000,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rpm - a.rpm)
|
||||||
|
.slice(0, 5);
|
||||||
|
const maxRpm = chartData.length > 0 ? Math.max(...chartData.map(d => d.rpm)) : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="yt-content">
|
||||||
|
{/* 대시보드 카드 3개 */}
|
||||||
|
<div className="yt-dash-cards">
|
||||||
|
<div className="yt-dash-card">
|
||||||
|
<div className="yt-dash-card__label">총 수익</div>
|
||||||
|
<div className="yt-dash-card__value yt-dash-card__value--green">
|
||||||
|
${dashboard?.total_revenue_usd?.toFixed(2) ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="yt-dash-card__sub">누적</div>
|
||||||
|
</div>
|
||||||
|
<div className="yt-dash-card">
|
||||||
|
<div className="yt-dash-card__label">총 조회수</div>
|
||||||
|
<div className="yt-dash-card__value yt-dash-card__value--blue">
|
||||||
|
{dashboard?.total_views != null
|
||||||
|
? (dashboard.total_views >= 1000
|
||||||
|
? `${(dashboard.total_views / 1000).toFixed(1)}K`
|
||||||
|
: String(dashboard.total_views))
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className="yt-dash-card__sub">누적</div>
|
||||||
|
</div>
|
||||||
|
<div className="yt-dash-card">
|
||||||
|
<div className="yt-dash-card__label">평균 RPM</div>
|
||||||
|
<div className="yt-dash-card__value yt-dash-card__value--amber">
|
||||||
|
${dashboard?.avg_rpm?.toFixed(2) ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div className="yt-dash-card__sub">가중평균</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 영상별 RPM 바 차트 */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="yt-card">
|
||||||
|
<h3 className="yt-card__title">영상별 RPM 비교</h3>
|
||||||
|
<div className="yt-bar-chart">
|
||||||
|
{chartData.map((d, i) => (
|
||||||
|
<div key={i} className="yt-bar-row">
|
||||||
|
<div className="yt-bar-row__label" title={d.label}>
|
||||||
|
{d.label.slice(0, 11)}
|
||||||
|
</div>
|
||||||
|
<div className="yt-bar-row__track">
|
||||||
|
<div
|
||||||
|
className="yt-bar-row__fill"
|
||||||
|
style={{ width: `${(d.rpm / maxRpm) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yt-bar-row__value">${d.rpm.toFixed(2)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수익 기록 추가 폼 */}
|
||||||
|
<div className="yt-card yt-card--create">
|
||||||
|
<h3 className="yt-card__title">+ 수익 기록 추가</h3>
|
||||||
|
<div className="yt-form-grid">
|
||||||
|
<div className="yt-field">
|
||||||
|
<label className="yt-field__label">YouTube 영상 ID</label>
|
||||||
|
<input
|
||||||
|
className="yt-input"
|
||||||
|
value={form.yt_video_id}
|
||||||
|
onChange={e => setForm(f => ({ ...f, yt_video_id: e.target.value }))}
|
||||||
|
placeholder="dQw4w9WgXcQ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yt-field">
|
||||||
|
<label className="yt-field__label">기록 월</label>
|
||||||
|
<input
|
||||||
|
className="yt-input"
|
||||||
|
type="month"
|
||||||
|
value={form.record_month}
|
||||||
|
onChange={e => setForm(f => ({ ...f, record_month: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yt-field">
|
||||||
|
<label className="yt-field__label">수익 (USD)</label>
|
||||||
|
<input
|
||||||
|
className="yt-input"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={form.revenue_usd}
|
||||||
|
onChange={e => setForm(f => ({ ...f, revenue_usd: e.target.value }))}
|
||||||
|
placeholder="3.45"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="yt-field">
|
||||||
|
<label className="yt-field__label">조회수</label>
|
||||||
|
<input
|
||||||
|
className="yt-input"
|
||||||
|
type="number"
|
||||||
|
value={form.views}
|
||||||
|
onChange={e => setForm(f => ({ ...f, views: e.target.value }))}
|
||||||
|
placeholder="1200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="yt-row yt-row--bottom">
|
||||||
|
<select
|
||||||
|
className="yt-select"
|
||||||
|
value={form.country}
|
||||||
|
onChange={e => setForm(f => ({ ...f, country: e.target.value }))}
|
||||||
|
>
|
||||||
|
{COUNTRIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn ms-btn--primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수익 기록 테이블 */}
|
||||||
|
<div className="yt-card">
|
||||||
|
<h3 className="yt-card__title">수익 기록</h3>
|
||||||
|
{records.length === 0 ? (
|
||||||
|
<p className="yt-empty">수익 기록이 없습니다. 위 폼으로 추가해보세요.</p>
|
||||||
|
) : (
|
||||||
|
<div className="yt-table">
|
||||||
|
<div className="yt-table__header">
|
||||||
|
<span>영상 ID</span>
|
||||||
|
<span>월</span>
|
||||||
|
<span>수익</span>
|
||||||
|
<span>조회수</span>
|
||||||
|
<span>RPM</span>
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{records.map(rec => (
|
||||||
|
editingId === rec.id ? (
|
||||||
|
<div key={rec.id} className="yt-table__row yt-table__row--editing">
|
||||||
|
<span className="yt-table__cell">{rec.yt_video_id.slice(0, 11)}</span>
|
||||||
|
<span className="yt-table__cell">{rec.record_month}</span>
|
||||||
|
<input
|
||||||
|
className="yt-input yt-input--sm"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={editForm.revenue_usd}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, revenue_usd: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="yt-input yt-input--sm"
|
||||||
|
type="number"
|
||||||
|
value={editForm.views}
|
||||||
|
onChange={e => setEditForm(f => ({ ...f, views: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<span className="yt-table__cell">—</span>
|
||||||
|
<div className="yt-table__actions">
|
||||||
|
<button type="button" className="ms-btn ms-btn--primary ms-btn--sm" onClick={handleEditSave}>저장</button>
|
||||||
|
<button type="button" className="ms-btn ms-btn--ghost ms-btn--sm" onClick={() => setEditingId(null)}>취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={rec.id}
|
||||||
|
className="yt-table__row"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(rec.id);
|
||||||
|
setEditForm({ revenue_usd: rec.revenue_usd, views: rec.views });
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<span className="yt-table__cell yt-table__cell--mono">{rec.yt_video_id.slice(0, 11)}</span>
|
||||||
|
<span className="yt-table__cell">{rec.record_month}</span>
|
||||||
|
<span className="yt-table__cell yt-table__cell--green">${rec.revenue_usd?.toFixed(2)}</span>
|
||||||
|
<span className="yt-table__cell">{rec.views?.toLocaleString()}</span>
|
||||||
|
<span className="yt-table__cell yt-table__cell--amber">
|
||||||
|
{rec.views > 0
|
||||||
|
? `$${((rec.revenue_usd / rec.views) * 1000).toFixed(2)}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ms-btn--icon ms-btn--danger"
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(rec.id); }}
|
||||||
|
aria-label="삭제"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user