feat: team org panel, admin CRUD, local deploy tools, bidirectional data sync

Add TeamMember model and APIs, team status UI, /admin page, local server bats,
and scripts to sync data between local PostgreSQL and Render.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
EENE Dashboard
2026-06-06 01:41:00 +09:00
parent d14ff1997c
commit fb2956b0ac
45 changed files with 4104 additions and 376 deletions

View File

@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
import type { TeamMember } from '../types';
export function useTeamMembers() {
return useQuery({
queryKey: ['team-members'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members');
return data;
},
staleTime: 60_000,
});
}

View File

@@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, getApiErrorMessage } from '../lib/apiClient';
import type { TeamMember } from '../types';
export interface TeamMemberForm {
name: string;
rank: string;
role: string;
cell: string;
contact: string;
photoUrl: string;
sortOrder: number;
}
export const EMPTY_MEMBER_FORM: TeamMemberForm = {
name: '',
rank: '',
role: '',
cell: 'HR',
contact: '',
photoUrl: '',
sortOrder: 0,
};
export function memberToForm(member: TeamMember): TeamMemberForm {
return {
name: member.name,
rank: member.rank ?? '',
role: member.role ?? '',
cell: member.cell ?? '리더',
contact: member.contact ?? '',
photoUrl: member.photoUrl ?? '',
sortOrder: member.sortOrder ?? 0,
};
}
export function formToPayload(form: TeamMemberForm) {
return {
name: form.name.trim(),
rank: form.rank.trim() || null,
role: form.role.trim() || null,
cell: form.cell.trim() || null,
contact: form.contact.trim() || null,
photoUrl: form.photoUrl.trim() || null,
sortOrder: Number(form.sortOrder) || 0,
};
}
export function useTeamMembersAdmin() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['team-members', 'admin'],
queryFn: async () => {
const { data } = await apiClient.get<TeamMember[]>('/team-members', {
params: { all: '1' },
});
return data.filter((m) => m.isActive !== false);
},
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['team-members'] });
queryClient.invalidateQueries({ queryKey: ['team-members', 'admin'] });
};
const create = useMutation({
mutationFn: (form: TeamMemberForm) =>
apiClient.post('/team-members', formToPayload(form)),
onSuccess: invalidate,
});
const update = useMutation({
mutationFn: ({ id, form }: { id: string; form: TeamMemberForm }) =>
apiClient.patch(`/team-members/${id}`, formToPayload(form)),
onSuccess: invalidate,
});
const remove = useMutation({
mutationFn: (id: string) => apiClient.delete(`/team-members/${id}`),
onSuccess: invalidate,
});
return { query, create, update, remove, getApiErrorMessage };
}