Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 115 additions & 23 deletions src/app/(authenticated)/admin/suggestions/page.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,8 +15,10 @@ import { DragDropContext, Droppable, Draggable, type OnDragEndResponder } from "
import { toast } from "@/hooks/use-toast"
import { api } from "@/trpc/react"
import type { RouterOutputs } from "@/trpc/react"
import { Settings, Plus, Edit, Trash2 } from "lucide-react"
import { Settings, Plus, Edit, Trash2, Check, ChevronsUpDown } from "lucide-react"
import { KpiManagementModal } from "@/components/admin/suggestion/kpi-management-modal"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"

// Usar tipos derivados do tRPC para garantir type safety
type DBSuggestion = RouterOutputs["suggestion"]["list"][number]
Expand All@@ -39,6 +41,7 @@ type SuggestionLocal = {
finalClassification: { label: string; range: string } | null
status: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason: string | null
analystId: string | null
user: {
firstName: string | null
lastName: string | null
Expand DownExpand Up@@ -107,6 +110,7 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
finalClassification: dbSuggestion.finalClassification as { label: string; range: string } | null,
status: dbSuggestion.status,
rejectionReason: dbSuggestion.rejectionReason,
analystId: dbSuggestion.analystId,
user: {
firstName: dbSuggestion.user.firstName,
lastName: dbSuggestion.user.lastName,
Expand All@@ -122,12 +126,100 @@ function convertDBToLocal(dbSuggestion: DBSuggestion): SuggestionLocal {
}
}

// Componente para seleção de usuário responsável
function UserSelector({
value,
onValueChange,
disabled = false
}: {
value: string | null
onValueChange: (value: string | null) => void
disabled?: boolean
}) {
const [open, setOpen] = useState(false)

// Buscar usuários para seleção de responsável
const { data: allUsers = [] } = api.user.listAll.useQuery()

const selectedUser = allUsers.find(user => user.id === value)

const getUserDisplayName = (user: typeof allUsers[0]) => {
if (user.firstName && user.lastName) {
return `${user.firstName} ${user.lastName}`
}
return user.email
}

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled}
>
{selectedUser ? getUserDisplayName(selectedUser) : "Selecionar responsável..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="Buscar usuário..." />
<CommandList>
<CommandEmpty>Nenhum usuário encontrado.</CommandEmpty>
<CommandGroup>
<CommandItem
onSelect={() => {
onValueChange(null)
setOpen(false)
}}
className="text-muted-foreground"
>
<Check className="mr-2 h-4 w-4 opacity-0" />
Não atribuído
</CommandItem>
{allUsers.map((user) => (
<CommandItem
key={user.id}
value={`${user.firstName ?? ''} ${user.lastName ?? ''} ${user.email}`.toLowerCase()}
onSelect={() => {
onValueChange(user.id)
setOpen(false)
}}
>
<Check
className={`mr-2 h-4 w-4 ${
value === user.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">
{getUserDisplayName(user)}
</span>
{user.setor && (
<span className="text-xs text-muted-foreground">
{user.setor}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

export default function AdminSuggestionsPage() {
const { data: dbSuggestions = [], refetch } = api.suggestion.list.useQuery({
status: ["NEW", "IN_REVIEW", "APPROVED", "IN_PROGRESS", "DONE", "NOT_IMPLEMENTED"],
})

const { data: currentUser } = api.user.me.useQuery()
const { data: _currentUser } = api.user.me.useQuery()

const suggestions = useMemo(() =>
dbSuggestions.map((s) => convertDBToLocal(s)),
Expand DownExpand Up@@ -217,33 +309,33 @@ export default function AdminSuggestionsPage() {

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _unusedKpiLoading = isLoadingKpis // Silenciar warning do linter

// Função para recarregar todas as classificações
const refetchAllClassifications = () => {
void refetchImpact()
void refetchCapacity()
void refetchEffort()
}

// Converter para o formato esperado pelo componente
const impactPool: ClassItem[] = impactClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const capacityPool: ClassItem[] = capacityClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const effortPool: ClassItem[] = effortClassifications?.map(c => ({
id: c.id,
label: c.label,
score: c.score
})) ?? []

const kpiPool: string[] = []

// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand DownExpand Up@@ -272,6 +364,7 @@ export default function AdminSuggestionsPage() {
kpiIds?: string[]
status?: "NEW" | "IN_REVIEW" | "APPROVED" | "IN_PROGRESS" | "DONE" | "NOT_IMPLEMENTED"
rejectionReason?: string
analystId?: string
} = { id }

if (updates.impact) updateData.impact = updates.impact
Expand All@@ -284,6 +377,9 @@ export default function AdminSuggestionsPage() {
if (updates.rejectionReason !== undefined) {
updateData.rejectionReason = updates.rejectionReason ?? undefined
}
if (updates.analystId !== undefined) {
updateData.analystId = updates.analystId ?? undefined
}

if (Object.keys(updateData).length > 1) {
await updateMutation.mutateAsync(updateData)
Expand DownExpand Up@@ -513,7 +609,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -531,7 +627,7 @@ export default function AdminSuggestionsPage() {
kpiPool={kpiPool}
currentSuggestionKpis={currentSuggestionKpis}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={openClassificationModal}
onOpenKpiModal={openKpiModal}
getStatusFromScore={getStatusFromScore}
Expand DownExpand Up@@ -589,7 +685,7 @@ function IdeasAccordion({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
currentSuggestionKpis,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -601,7 +697,7 @@ function IdeasAccordion({
kpiPool: string[]
currentSuggestionKpis: { id: string; name: string; description?: string | null }[]
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -679,7 +775,7 @@ function IdeasAccordion({
saveRejectionReason={saveRejectionReason}
sendRejectionNotification={sendRejectionNotification}
update={update}
currentUser={currentUser}
_currentUser={_currentUser}
onOpenClassificationModal={onOpenClassificationModal}
onOpenKpiModal={onOpenKpiModal}
getStatusFromScore={getStatusFromScore}
Expand All@@ -696,7 +792,7 @@ function SuggestionItem({
saveRejectionReason,
sendRejectionNotification,
update,
currentUser,
_currentUser,
onOpenClassificationModal,
onOpenKpiModal,
getStatusFromScore,
Expand All@@ -707,7 +803,7 @@ function SuggestionItem({
saveRejectionReason: (suggestionId: string) => Promise<void>
sendRejectionNotification: ReturnType<typeof api.suggestion.sendRejectionNotification.useMutation>
update: (id: string, updates: Partial<SuggestionLocal>) => void
currentUser: RouterOutputs["user"]["me"] | undefined
_currentUser: RouterOutputs["user"]["me"] | undefined
onOpenClassificationModal: (suggestionId: string, type: 'impact' | 'capacity' | 'effort') => void
onOpenKpiModal: (suggestionId: string) => void
getStatusFromScore: (suggestion: SuggestionLocal) => string
Expand DownExpand Up@@ -940,15 +1036,11 @@ function SuggestionItem({
</div>
<div>
<label className="text-sm">Responsável pela devolutiva</label>
<Input
value={
currentUser && 'firstName' in currentUser && 'lastName' in currentUser && currentUser.firstName && currentUser.lastName
? `${currentUser.firstName} ${currentUser.lastName}`
: currentUser && 'email' in currentUser
? currentUser.email
: "Admin Atual"
}
readOnly
<UserSelector
value={s.analystId}
onValueChange={(userId: string | null) => {
update(s.id, { analystId: userId })
}}
/>
</div>

Expand Down
24 changes: 21 additions & 3 deletions src/server/api/routers/suggestions.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,24 @@ export const suggestionRouter = createTRPCRouter({
orderBy: { createdAt: "desc" },
take: input?.take,
skip: input?.skip,
include: {
select: {
id: true,
ideaNumber: true,
submittedName: true,
submittedSector: true,
isNameVisible: true,
description: true,
contribution: true,
dateRef: true,
impact: true,
capacity: true,
effort: true,
finalScore: true,
finalClassification: true,
status: true,
rejectionReason: true,
analystId: true,
createdAt: true,
user: {
select: {
firstName: true,
Expand DownExpand Up@@ -142,7 +159,7 @@ export const suggestionRouter = createTRPCRouter({
})
}),

// Atualizações do admin (impact/capacity/effort/kpis/status/reason)
// Atualizações do admin (impact/capacity/effort/kpis/status/reason/analyst)
updateAdmin: adminProcedure
.input(z.object({
id: z.string(),
Expand All@@ -152,6 +169,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: z.array(z.string()).optional(),
status: StatusEnum.optional(),
rejectionReason: z.string().optional(),
analystId: z.string().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Allow null to enable unassigning an analyst (tri-state: string | null | undefined)

Right now analystId is z.string().optional(). That makes it impossible to intentionally clear the responsible analyst: sending null fails validation; omitting the field (undefined) currently triggers a fallback (see Line 240) that will force-assign the current admin. Support null so the client can unassign.

Apply:

- analystId: z.string().optional(),+ // string = assign to user; null = unassign; undefined = do not change+ analystId: z.string().nullable().optional(),
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
analystId: z.string().optional(),
// string = assign to user; null = unassign; undefined = do not change
analystId: z.string().nullable().optional(),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 172, the Zod schema for
analystId should accept null so clients can explicitly unassign; change the
schema to allow string | null | undefined (e.g., make it nullable and optional).
Also adjust the handling around line 240 so the "fallback to current admin" only
runs when analystId is strictly undefined (preserve existing behavior for
omitted field) and treat a null value as an explicit unassign (do not replace it
with the current admin).

}))
.mutation(async ({ ctx, input }) => {
const prev = await ctx.db.suggestion.findUnique({
Expand DownExpand Up@@ -219,7 +237,7 @@ export const suggestionRouter = createTRPCRouter({
kpis: (input.kpis ?? null) as InputJsonValue,
status: input.status,
rejectionReason: input.rejectionReason,
analystId: ctx.auth.userId,
analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not force-assign analyst on every admin update; honor undefined vs null

analystId: input.analystId ?? ctx.auth.userId reassigns the analyst to the current admin on any update that omits analystId. It also prevents unassigning even if the client sends null (because ?? coalesces null). This will silently overwrite the responsible analyst during unrelated updates (status, scores, etc.).

Use tri-state semantics:

  • undefined: don't touch analystId
  • string: assign to that user
  • null: clear the assignment

Apply:

- analystId: input.analystId ?? ctx.auth.userId, // Usar analystId fornecido ou fallback para usuário atual+ // Only modify when provided; allow null to clear+ ...(input.analystId !== undefined ? { analystId: input.analystId } : {}),

Optionally, if you want a one-time default assignment when there is no analyst yet, gate it explicitly:

// Before update, fetch current analystIdconstcurrent=awaitctx.db.suggestion.findUnique({where: {id: input.id},select: {analystId: true}});// In data:
...(input.analystId!==undefined
? {analystId: input.analystId}
: current?.analystId ? {} : {/* decide whether to set a default here or leave null */}),
🤖 Prompt for AI Agents
In src/server/api/routers/suggestions.ts around line 240, the current use of
"analystId: input.analystId ?? ctx.auth.userId" force-assigns the analyst on
every update and treats null as undefined; change this to tri-state handling:
fetch the current suggestion's analystId first, then when building the update
data only include analystId if input.analystId !== undefined (if string, set it;
if null, set it to null to clear), and do not fall back to ctx.auth.userId
implicitly — if you want a one-time default when there is no analyst, gate that
explicitly based on the fetched current value.

finalScore,
finalClassification: finalClassification as InputJsonValue,
},
Expand Down