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:
parent
f6a29db95a
commit
cfc58458a8
21 changed files with 985 additions and 125 deletions
171
apps/web/src/components/teams/activity-feed.tsx
Normal file
171
apps/web/src/components/teams/activity-feed.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue