diff --git a/app/api/companies/[ws]/mail/route.js b/app/api/companies/[ws]/mail/route.js new file mode 100644 index 0000000..c5dae4e --- /dev/null +++ b/app/api/companies/[ws]/mail/route.js @@ -0,0 +1,58 @@ +// 쪽지함 — 크루 우편함(src/crewmail.mjs)의 화면용 API. GET 목록 / POST 사장 발신 / DELETE 대기 취소 / PATCH 실패함 조작. +import { listMail, sendCrewMail, cancelMail, requeueDead, deleteDead } from '../../../../../src/crewmail.mjs'; +import { loadCompany } from '../../../../../src/workspace.mjs'; +import { guardCompany } from '../../../../auth.mjs'; + +export async function GET(_req, { params }) { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + try { + return Response.json(await listMail(ws)); + } catch (e) { + return Response.json({ error: String(e.message || e) }, { status: 500 }); + } +} + +/** 사장 발신 — room.mjs의 회의실 cc와 같은 신원(from:'captain', fromRole:'captain'). 이름은 회사 언어에 따른 호칭. */ +export async function POST(req, { params }) { + try { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const { to, cc = [], message } = await req.json(); + if (!to || !String(message ?? '').trim()) return Response.json({ error: '수신 크루와 내용이 필요합니다' }, { status: 400 }); + const { lang = 'ko' } = await loadCompany(ws).catch(() => ({})); + const id = await sendCrewMail(ws, { + from: 'captain', fromName: lang === 'en' ? 'the captain' : '사장', fromRole: 'captain', + to: String(to), cc: Array.isArray(cc) ? cc.map(String) : [], message: String(message), + }); + return Response.json({ id }); + } catch (e) { + return Response.json({ error: String(e.message || e) }, { status: 400 }); + } +} + +export async function DELETE(req, { params }) { + try { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const url = new URL(req.url); + const to = url.searchParams.get('to'); const id = url.searchParams.get('id'); + if (!to || !id) return Response.json({ error: 'to·id가 필요합니다' }, { status: 400 }); + return Response.json(await cancelMail(ws, to, id)); + } catch (e) { + return Response.json({ error: String(e.message || e) }, { status: 400 }); + } +} + +export async function PATCH(req, { params }) { + try { + const { ws } = await params; + const denied = await guardCompany(ws); if (denied) return denied; + const { op, file } = await req.json(); + if (op === 'requeue') return Response.json(await requeueDead(ws, file)); + if (op === 'deleteDead') return Response.json(await deleteDead(ws, file)); + return Response.json({ error: 'op는 requeue 또는 deleteDead입니다' }, { status: 400 }); + } catch (e) { + return Response.json({ error: String(e.message || e) }, { status: 400 }); + } +} diff --git a/app/c/[ws]/layout.jsx b/app/c/[ws]/layout.jsx index bc37a25..389e69e 100644 --- a/app/c/[ws]/layout.jsx +++ b/app/c/[ws]/layout.jsx @@ -220,6 +220,7 @@ export default function CompanyShell({ children, params }) { : pathname.endsWith('/routines') ? t('nav.routines') : pathname.endsWith('/market') ? t('nav.market') : pathname.endsWith('/activity') ? t('nav.activity') + : pathname.endsWith('/mail') ? t('nav.mail') : pathname.endsWith('/settings') ? t('nav.settings') : currentCrew ? currentCrew.name : t('nav.deck'); // 사이드바 크루 — 고정(pin) 크루는 최상단 '고정' 그룹으로, 나머지는 팀별 그룹(팀 없는 크루는 마지막). @@ -270,6 +271,9 @@ export default function CompanyShell({ children, params }) { {t('nav.activity')} + + {t('nav.mail')} + {t('nav.market')} diff --git a/app/c/[ws]/mail/page.jsx b/app/c/[ws]/mail/page.jsx new file mode 100644 index 0000000..151701c --- /dev/null +++ b/app/c/[ws]/mail/page.jsx @@ -0,0 +1,250 @@ +'use client'; +// 쪽지함 — 크루 우편함(mail/)의 화면. 사장 발신 + 대기/배달 기록/실패함. 5초 폴링(배달은 스케줄러가 1분 틱으로). +import { use, useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Icon, Avatar, Spinner, Skeleton, ConfirmModal, DropUp, api, timeAgo } from '../../../ui'; +import { useLang } from '../../../i18n'; + +const CC_MAX = 4; // src/crewmail.mjs CC_MAX와 같은 값 — 서버가 최종 강제, 여기선 안내·토글 상한 + +export default function Mail({ params }) { + const { ws } = use(params); + const { t, lang } = useLang(); + const [data, setData] = useState(null); + const [agents, setAgents] = useState([]); + const [error, setError] = useState(''); + const [to, setTo] = useState(''); + const [cc, setCc] = useState([]); + const [message, setMessage] = useState(''); + const [sending, setSending] = useState(false); + const [notice, setNotice] = useState(''); + const [busy, setBusy] = useState(''); // 진행 중인 행 조작 키 + const [delTarget, setDelTarget] = useState(null); + + const load = useCallback(() => api(`/api/companies/${ws}/mail`) + .then((d) => { setData(d); setError(''); }) + .catch((e) => { setError(String(e?.message || '') || t('mail.loadFail')); }), [ws, t]); + + useEffect(() => { + api(`/api/companies/${ws}`).then((d) => setAgents(d.agents ?? [])).catch(() => {}); + }, [ws]); + useEffect(() => { + load(); + const iv = setInterval(load, 5000); + return () => clearInterval(iv); + }, [load]); + useEffect(() => { if (!to && agents.length) setTo(agents[0].slug); }, [agents, to]); + + const nameOf = (slug) => (slug === 'captain' ? t('mail.fromCaptain') : (agents.find((a) => a.slug === slug)?.name ?? slug)); + + async function send(e) { + e.preventDefault(); + if (sending || !to || !message.trim()) return; + setSending(true); setError(''); setNotice(''); + try { + await api(`/api/companies/${ws}/mail`, { to, cc: cc.filter((s) => s !== to), message }); + setMessage(''); setCc([]); setNotice(t('mail.sent')); + load(); + } catch (err) { + setError(String(err.message)); + } finally { setSending(false); } + } + + async function cancel(m) { + setBusy(`c:${m.to}:${m.id}`); setError(''); + try { + await fetch(`/api/companies/${ws}/mail?to=${encodeURIComponent(m.to)}&id=${encodeURIComponent(m.id)}`, { method: 'DELETE' }) + .then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); }); + load(); + } catch (err) { setError(String(err.message)); } finally { setBusy(''); } + } + + async function patch(op, file) { + setBusy(`${op}:${file}`); setError(''); + try { + await fetch(`/api/companies/${ws}/mail`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op, file }) }) + .then(async (r) => { if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status); }); + load(); + } catch (err) { setError(String(err.message)); } finally { setBusy(''); } + } + + async function doDelete() { + const d = delTarget; if (!d) return; + setDelTarget(null); // 모달을 await 전에 닫아 더블클릭 이중 요청 차단(루틴 페이지와 동일) + await patch('deleteDead', d.file); + } + + const toggleCc = (slug) => setCc((cur) => (cur.includes(slug) ? cur.filter((s) => s !== slug) : (cur.length >= CC_MAX ? cur : [...cur, slug]))); + const pending = data?.pending ?? []; + const log = data?.log ?? []; + const dead = data?.dead ?? []; + const kindChip = (k) => {k === 'cc' ? t('mail.kind.cc') : t('mail.kind.to')}; + const who = (slug) => ( + + {nameOf(slug)} + + ); + + return ( +
+ {delTarget && ( + setDelTarget(null)} /> + )} +

{t('mail.intro')}

+ + {/* 보내기 */} +
+ {t('mail.compose')} + {agents.length === 0 ? ( +

{t('mail.noCrew')}

+ ) : ( + <> +
+
+ {t('mail.to')} + ({ value: a.slug, label: `${a.name} — ${a.role}` })) }]} + onChange={(v) => { setTo(v); setCc((cur) => cur.filter((s) => s !== v)); }} /> +
+
+ {t('mail.cc')} — {t('mail.ccHint', { n: CC_MAX })} +
+ {agents.filter((a) => a.slug !== to).map((a) => { + const on = cc.includes(a.slug); + return ( + + ); + })} +
+
+
+