feat(web): manage dictionaries and teams from the console

The console could not create dictionary entries, and team pages showed a
static member list with no record of who changed what. Dictionary
management, a knowledge upload form, and a team activity feed are now
available, alongside a download center that links the published desktop
installer feed rather than repository-local paths that no deploy ships.

Red-team e2e coverage was added for the account and team flows touched here.
This commit is contained in:
Yun Chan 2026-09-16 23:24:27 +09:00
parent f6a29db95a
commit cfc58458a8
21 changed files with 985 additions and 125 deletions

View file

@ -0,0 +1,171 @@
'use client'
// apps/web/src/components/teams/activity-feed.tsx
// 팀 활동/코멘트 피드 — RPC로 작성하고 Realtime INSERT를 구독한다.
import { useEffect, useState } from 'react'
import { Alert, Box, Button, Stack, TextField } from '@mui/material'
import { d3roPalette } from '@d3ro/ui/theme'
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
export interface TeamActivityItem {
id: string
actor_id: string | null
kind: string
body: string | null
created_at: string
}
interface ActivityFeedProps {
teamId: string
initialActivities: TeamActivityItem[]
memberNames: Record<string, string>
canPost: boolean
}
const KIND_LABELS: Record<string, string> = {
note: '메모',
member_joined: '멤버 합류',
member_left: '멤버 탈퇴',
invite_created: '초대 생성',
meeting_shared: '회의 공유',
document_shared: '문서 공유'
}
function formatTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString('ko-KR')
}
export function TeamActivityFeed({
teamId,
initialActivities,
memberNames,
canPost
}: ActivityFeedProps): React.ReactElement {
const [activities, setActivities] = useState<TeamActivityItem[]>(initialActivities)
const [body, setBody] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setActivities(initialActivities)
}, [initialActivities])
useEffect(() => {
const supabase = getSupabaseBrowserClient()
const channel = supabase
.channel(`web-team-activity-${teamId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` },
(payload) => {
const row = payload.new as TeamActivityItem
setActivities((current) =>
current.some((item) => item.id === row.id) ? current : [row, ...current]
)
}
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [teamId])
async function submit(): Promise<void> {
const trimmed = body.trim()
if (!trimmed || busy) return
setBusy(true)
setError(null)
try {
const supabase = getSupabaseBrowserClient()
const { data, error: rpcError } = await supabase.rpc('create_team_activity', {
p_team_id: teamId,
p_kind: 'note',
p_body: trimmed,
p_metadata: {}
})
if (rpcError) {
setError(rpcError.message)
return
}
setBody('')
const created = data as { id?: string; created_at?: string; actor_id?: string } | null
if (created?.id) {
setActivities((current) =>
current.some((item) => item.id === created.id)
? current
: [
{
id: created.id as string,
actor_id: (created.actor_id as string | undefined) ?? null,
kind: 'note',
body: trimmed,
created_at: created.created_at ?? new Date().toISOString()
},
...current
]
)
}
} finally {
setBusy(false)
}
}
return (
<Stack spacing={2}>
{canPost && (
<Stack direction="row" spacing={1} alignItems="flex-start">
<TextField
size="small"
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder="팀에 메모 남기기..."
inputProps={{ maxLength: 2000 }}
fullWidth
multiline
maxRows={4}
disabled={busy}
/>
<Button variant="contained" onClick={() => void submit()} disabled={busy || !body.trim()}>
{busy ? '등록 중' : '등록'}
</Button>
</Stack>
)}
{error && (
<Alert severity="error" variant="outlined">
{error}
</Alert>
)}
{activities.length === 0 ? (
<Box sx={{ color: d3roPalette.text.muted, fontSize: 13 }}>아직 활동이 없습니다.</Box>
) : (
<Stack spacing={1}>
{activities.map((activity) => (
<Box key={activity.id} sx={{ p: 1.5, bgcolor: d3roPalette.bg.inset, borderRadius: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, mb: 0.5 }}>
<Box sx={{ color: d3roPalette.text.primary, fontSize: 12, fontWeight: 500 }}>
{activity.actor_id ? memberNames[activity.actor_id] ?? activity.actor_id.slice(0, 8) : '시스템'}
<Box component="span" sx={{ color: d3roPalette.text.muted, ml: 1, fontWeight: 400 }}>
{KIND_LABELS[activity.kind] ?? activity.kind}
</Box>
</Box>
<Box sx={{ color: d3roPalette.text.muted, fontSize: 11 }}>
{formatTime(activity.created_at)}
</Box>
</Box>
{activity.body && (
<Box sx={{ color: d3roPalette.text.primary, fontSize: 13, whiteSpace: 'pre-wrap' }}>
{activity.body}
</Box>
)}
</Box>
))}
</Stack>
)}
</Stack>
)
}