mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 10:43:07 +00:00
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
|
|
import { CopyIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react'
|
|
|
|
import type {
|
|
GoogleCalendarOAuthStartResult,
|
|
GoogleCalendarSyncActionResult,
|
|
} from '@/app/(campaign)/campanha/actions/googleCalendarSync'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
Drawer,
|
|
DrawerCloseButton,
|
|
DrawerContent,
|
|
DrawerDescription,
|
|
DrawerFooter,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
} from '@/components/ui/Drawer'
|
|
import { Input } from '@/components/ui/input'
|
|
import { useIsMobile } from '@/hooks/use-mobile'
|
|
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
|
|
import {
|
|
buildGoogleCalendarAddLink,
|
|
buildGoogleCalendarPublicIcalUrl,
|
|
} from '@/lib/googleCalendarLink'
|
|
import type { GoogleCalendarConnectionStatus } from '@/utilities/googleCalendarSync'
|
|
|
|
type GoogleCalendarSyncDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
state: GoogleCalendarSyncActionResult
|
|
onSyncNow: () => Promise<GoogleCalendarSyncActionResult>
|
|
onSetDisabled: (disabled: boolean) => Promise<GoogleCalendarSyncActionResult>
|
|
/** C149 — starts the OAuth handshake; the caller navigates to the returned URL. */
|
|
onStartOAuth: () => Promise<GoogleCalendarOAuthStartResult>
|
|
/** C149 — drops the OAuth connection from the Teqo. */
|
|
onDisconnect: () => Promise<GoogleCalendarSyncActionResult>
|
|
/** C150 — opens the primary-calendar picker (candidate/coordination only). */
|
|
onOpenPicker: () => void
|
|
}
|
|
|
|
const NOT_CONFIGURED_RUNBOOK: readonly string[] = [
|
|
'Crie o calendário "Agenda da Campanha" na conta Google da campanha.',
|
|
'No Google, compartilhe o calendário como público (qualquer pessoa com o link pode ver).',
|
|
'Dê permissão de edição ("fazer alterações em eventos") à service account do Teqo.',
|
|
'Um administrador configura o ID do calendário no Painel e a chave da service account nas variáveis de ambiente.',
|
|
]
|
|
|
|
const CONNECTION_BADGE: Record<
|
|
GoogleCalendarConnectionStatus,
|
|
{ label: string; className: string }
|
|
> = {
|
|
connected: {
|
|
label: 'Conectado',
|
|
className: 'border-green-300 bg-green-50 text-green-700',
|
|
},
|
|
error: {
|
|
label: 'Erro',
|
|
className: 'border-amber-300 bg-amber-50 text-amber-700',
|
|
},
|
|
'not-configured': {
|
|
label: 'Não configurado',
|
|
className: 'border-border text-muted-foreground',
|
|
},
|
|
}
|
|
|
|
const copyIcon = <CopyIcon className="h-4 w-4" />
|
|
|
|
/**
|
|
* C114/C115/C149 — Google Calendar mirror dialog. The OAuth connection card
|
|
* (C149) leads every state: `não configurado` offers the one-click connect
|
|
* (or the admin runbook when the server has no OAuth client), `conectado`
|
|
* offers reconnect/disconnect and `erro` offers the reconnect path. The
|
|
* mirror-state blocks (C114/C115) stay below, untouched. Bottom sheet on
|
|
* mobile, dialog on desktop — same chrome as the iCal feed dialog (C94).
|
|
*/
|
|
export const GoogleCalendarSyncDialog = ({
|
|
open,
|
|
onOpenChange,
|
|
state,
|
|
onSyncNow,
|
|
onSetDisabled,
|
|
onStartOAuth,
|
|
onDisconnect,
|
|
onOpenPicker,
|
|
}: GoogleCalendarSyncDialogProps) => {
|
|
const isMobile = useIsMobile()
|
|
const [isBusy, setIsBusy] = useState(false)
|
|
const [copied, setCopied] = useState(false)
|
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
|
|
const resetTransientState = () => {
|
|
setCopied(false)
|
|
setActionError(null)
|
|
}
|
|
|
|
// C150 — the one-click button and the manual path ("Por URL", Apple
|
|
// Calendar, Outlook) are both derived from the configured calendar.
|
|
const addLink = state.calendarId ? buildGoogleCalendarAddLink(state.calendarId) : null
|
|
const icalUrl = state.calendarId ? buildGoogleCalendarPublicIcalUrl(state.calendarId) : null
|
|
|
|
const handleCopy = async () => {
|
|
if (!icalUrl) return
|
|
await navigator.clipboard.writeText(icalUrl)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
/**
|
|
* Shared busy/error envelope: every card action maps a failed result to the
|
|
* same `actionError` line and never leaves the buttons stuck in busy.
|
|
*/
|
|
const runAction = async <T extends { ok: boolean; message?: string }>(
|
|
run: () => Promise<T>,
|
|
fallbackMessage: string,
|
|
): Promise<T | null> => {
|
|
if (isBusy) return null
|
|
setIsBusy(true)
|
|
setActionError(null)
|
|
try {
|
|
const result = await run()
|
|
if (!result.ok) setActionError(result.message ?? fallbackMessage)
|
|
return result
|
|
} finally {
|
|
setIsBusy(false)
|
|
}
|
|
}
|
|
|
|
const handleSyncNow = () => runAction(onSyncNow, 'Não foi possível sincronizar.')
|
|
|
|
const handleSetDisabled = (disabled: boolean) =>
|
|
runAction(() => onSetDisabled(disabled), 'Não foi possível atualizar.')
|
|
|
|
const handleStartOAuth = async () => {
|
|
const result = await runAction(onStartOAuth, 'Não foi possível iniciar a conexão.')
|
|
if (result?.ok) window.location.assign(result.authorizeUrl)
|
|
}
|
|
|
|
const handleDisconnect = () => runAction(onDisconnect, 'Não foi possível desconectar.')
|
|
|
|
const linkBlock = addLink ? (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium">Adicionar ao meu Google Calendar</p>
|
|
<Button asChild className="w-full sm:w-auto">
|
|
<a href={addLink} target="_blank" rel="noopener noreferrer">
|
|
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
|
Adicionar ao meu Google Calendar
|
|
</a>
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
O Google abre com o calendário da campanha pronto para assinar — vale para o computador e
|
|
o celular.
|
|
</p>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="google-calendar-link">
|
|
URL pública do calendário (Por URL, Apple Calendar e Outlook)
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="google-calendar-link"
|
|
value={icalUrl ?? ''}
|
|
readOnly
|
|
className="font-mono text-xs"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={handleCopy}
|
|
aria-label="Copiar link"
|
|
>
|
|
{copyIcon}
|
|
</Button>
|
|
</div>
|
|
{copied && (
|
|
<p className="text-xs text-green-600" aria-live="polite">
|
|
Link copiado!
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Quem segue o calendário recebe aviso conforme as próprias configurações do Google.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : null
|
|
|
|
const instructions = (
|
|
<div className="rounded-lg bg-muted p-4 text-sm">
|
|
<p className="mb-2 font-medium">Como adicionar ao Google Calendar:</p>
|
|
<ol className="list-inside list-decimal space-y-1 text-muted-foreground">
|
|
<li>Abra o Google Calendar no computador</li>
|
|
<li>
|
|
No menu lateral, clique em {'"'}
|
|
{'+'}
|
|
{'"'} ao lado de {'"'}Outras agendas{'"'}
|
|
</li>
|
|
<li>
|
|
Selecione {'"'}Por URL{'"'}
|
|
</li>
|
|
<li>
|
|
Cole o link acima e clique em {'"'}Adicionar agenda{'"'}
|
|
</li>
|
|
</ol>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Apple Calendar e Outlook assinam a URL iCal pública do mesmo calendário.
|
|
</p>
|
|
</div>
|
|
)
|
|
|
|
const reverseEditBlock = (
|
|
<div className="rounded-lg bg-muted p-4 text-sm">
|
|
<p className="mb-1 font-medium">Edições pelo Google</p>
|
|
<p className="text-muted-foreground">
|
|
Quem tem permissão de edição no calendário pode remarcar, renomear ou cancelar o compromisso
|
|
direto no Google — a mudança volta para a atividade do Teqo automaticamente e fica
|
|
registrada nas atualizações. Título e horário seguem editáveis por lá; os demais campos só
|
|
mudam pelo Teqo.
|
|
</p>
|
|
{state.pushChannelExpiresAt ? (
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Notificações ativas até {formatBahiaDateTimeLabel(state.pushChannelExpiresAt)} — renovadas
|
|
automaticamente a cada sincronização.
|
|
</p>
|
|
) : null}
|
|
{state.pushChannelError ? (
|
|
<p className="mt-2 text-xs text-amber-700">
|
|
Notificações de mudanças feitas no Google indisponíveis: {state.pushChannelError}. O Teqo
|
|
continua detectando as mudanças ao sincronizar.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)
|
|
|
|
const syncButton = (
|
|
<Button type="button" onClick={handleSyncNow} disabled={isBusy}>
|
|
<RefreshCwIcon className={`mr-2 h-4 w-4 ${isBusy ? 'animate-spin' : ''}`} />
|
|
{isBusy ? 'Sincronizando...' : 'Sincronizar agora'}
|
|
</Button>
|
|
)
|
|
|
|
const connectionActions = state.canManageConnection ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{state.connection === 'connected' ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void handleStartOAuth()}
|
|
disabled={isBusy}
|
|
>
|
|
Reconectar com o Google
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
onClick={() => void handleStartOAuth()}
|
|
disabled={isBusy || !state.oauthAvailable}
|
|
>
|
|
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
|
{state.connection === 'error' ? 'Reconectar com o Google' : 'Conectar com o Google'}
|
|
</Button>
|
|
)}
|
|
{state.connection !== 'not-configured' ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void handleDisconnect()}
|
|
disabled={isBusy}
|
|
>
|
|
Desconectar
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
Somente candidato ou coordenação pode conectar ou desconectar a conta Google da campanha.
|
|
</p>
|
|
)
|
|
|
|
const oauthUnavailableBlock =
|
|
state.connection === 'not-configured' && !state.oauthAvailable ? (
|
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
|
<p className="mb-2 font-medium text-amber-900">Ainda não configurado</p>
|
|
<p className="text-amber-800">
|
|
O Teqo segue 100% funcional — a agenda do Teqo nunca depende do Google. Um administrador
|
|
pode concluir a configuração manual:
|
|
</p>
|
|
<ol className="mt-2 list-inside list-decimal space-y-1 text-amber-800">
|
|
{NOT_CONFIGURED_RUNBOOK.map((step) => (
|
|
<li key={step}>{step}</li>
|
|
))}
|
|
</ol>
|
|
</div>
|
|
) : null
|
|
|
|
const connectionBlock = (
|
|
<div className="rounded-lg border border-border p-4">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Google Calendar</h3>
|
|
{state.connection === 'not-configured' ? (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Conecte a conta Google do calendário da campanha para gerenciar o espelho por ela.
|
|
{state.status !== 'not-configured'
|
|
? ' O espelho segue ativo pela configuração manual (service account).'
|
|
: ''}
|
|
</p>
|
|
) : state.connection === 'connected' ? (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
O espelho usa a conta Google conectada para escrever os compromissos no calendário da
|
|
campanha.
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
A conexão com o Google precisa ser refeita.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<span
|
|
className={`self-start rounded-full border px-3 py-1 text-xs font-medium ${CONNECTION_BADGE[state.connection].className}`}
|
|
>
|
|
{CONNECTION_BADGE[state.connection].label}
|
|
</span>
|
|
</div>
|
|
|
|
{state.connection === 'not-configured' ? (
|
|
<div className="mt-4 space-y-3">
|
|
{oauthUnavailableBlock ?? (
|
|
<div className="rounded-md border border-dashed border-border p-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Você será levado à tela de consentimento do Google para autorizar o Teqo a gerenciar
|
|
o calendário da campanha.
|
|
</p>
|
|
<div className="mt-3">{connectionActions}</div>
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
A configuração manual por service account continua disponível como fallback técnico.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{state.connection === 'connected' ? (
|
|
<div className="mt-4 space-y-3">
|
|
<p className="text-sm text-muted-foreground">
|
|
{state.oauthConnectedAt
|
|
? `Conta Google conectada em ${formatBahiaDateTimeLabel(state.oauthConnectedAt)}.`
|
|
: 'Conta Google conectada.'}
|
|
</p>
|
|
<div className="rounded-md border border-dashed border-border p-4">
|
|
<p className="text-sm font-medium">Calendário principal da campanha</p>
|
|
{state.canManageConnection ? (
|
|
<>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{state.calendarId
|
|
? 'O espelho escreve neste calendário:'
|
|
: 'Nenhum calendário escolhido ainda — o espelho fica parado até a escolha.'}
|
|
</p>
|
|
{state.calendarId ? (
|
|
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
|
{state.calendarId}
|
|
</p>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-3"
|
|
onClick={onOpenPicker}
|
|
disabled={isBusy}
|
|
>
|
|
{state.calendarId
|
|
? 'Trocar calendário principal'
|
|
: 'Escolher calendário principal'}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
O calendário principal é escolhido por candidato ou coordenação.
|
|
</p>
|
|
)}
|
|
</div>
|
|
{connectionActions}
|
|
<p className="text-xs text-muted-foreground">
|
|
Desconectar revoga o acesso do Teqo; depois, revogue o app nas configurações de
|
|
segurança da conta Google.
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{state.connection === 'error' ? (
|
|
<div className="mt-4 space-y-3">
|
|
<div className="rounded-md border border-amber-300 bg-amber-50 p-4 text-sm">
|
|
<p className="font-medium text-amber-900">Erro de conexão com o Google</p>
|
|
<p className="mt-1 text-amber-800">
|
|
{state.oauthError ??
|
|
'Não foi possível renovar o acesso. O Teqo continua sendo a fonte da verdade — nada foi perdido.'}
|
|
</p>
|
|
<p className="mt-2 text-xs text-amber-700">
|
|
Reconecte com a mesma conta Google do calendário da campanha. Se a conta mudou,
|
|
desconecte antes e conecte novamente.
|
|
</p>
|
|
</div>
|
|
{connectionActions}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
|
|
let content: React.ReactNode
|
|
let actions: React.ReactNode = null
|
|
|
|
if (state.status === 'not-configured') {
|
|
content =
|
|
state.connection === 'connected' ? (
|
|
<p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
|
{state.canManageConnection
|
|
? 'Escolha o calendário principal para a agenda começar a espelhar no Google.'
|
|
: 'O calendário principal da campanha é escolhido por candidato ou coordenação.'}
|
|
</p>
|
|
) : null
|
|
} else if (state.status === 'disabled') {
|
|
content = (
|
|
<div className="rounded-lg bg-muted p-4 text-sm text-muted-foreground">
|
|
A sincronização está desativada. A agenda do Teqo continua funcionando normalmente; nada é
|
|
enviado ao Google enquanto estiver desativada.
|
|
</div>
|
|
)
|
|
actions = (
|
|
<div className="flex justify-end">
|
|
<Button type="button" onClick={() => void handleSetDisabled(false)} disabled={isBusy}>
|
|
Reativar
|
|
</Button>
|
|
</div>
|
|
)
|
|
} else {
|
|
const paused = state.status === 'paused'
|
|
content = (
|
|
<>
|
|
{paused ? (
|
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 text-sm">
|
|
<p className="mb-1 font-medium text-amber-900">Pausado — re-tentando</p>
|
|
<p className="text-amber-800">
|
|
A última tentativa de sincronização falhou. O Teqo continua funcionando; o espelho é
|
|
tentado de novo automaticamente ao salvar uma atividade ou ao abrir esta página.
|
|
</p>
|
|
{state.lastErrorAt ? (
|
|
<p className="mt-2 text-xs text-amber-700">
|
|
Última tentativa: {formatBahiaDateTimeLabel(state.lastErrorAt)} —{' '}
|
|
{state.lastError ?? 'erro desconhecido'}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p className="rounded-lg bg-green-50 p-3 text-sm text-green-800">
|
|
Sincronizado — as mudanças da agenda já refletiram no Google.
|
|
{state.lastSuccessAt
|
|
? ` Última sincronização: ${formatBahiaDateTimeLabel(state.lastSuccessAt)}.`
|
|
: ''}
|
|
</p>
|
|
)}
|
|
|
|
{linkBlock}
|
|
{instructions}
|
|
{reverseEditBlock}
|
|
</>
|
|
)
|
|
actions = (
|
|
<div className="flex justify-end gap-2">
|
|
{paused ? (
|
|
<Button asChild variant="outline">
|
|
<a
|
|
href="https://calendar.google.com/calendar/u/0/r/settings/addbyurl"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<ExternalLinkIcon className="mr-2 h-4 w-4" />
|
|
Abrir Google Calendar
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
{syncButton}
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => void handleSetDisabled(true)}
|
|
disabled={isBusy}
|
|
>
|
|
Desativar
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const errorNotice = actionError ? (
|
|
<p role="alert" className="text-sm text-red-600">
|
|
{actionError}
|
|
</p>
|
|
) : null
|
|
|
|
// C149 — the connection card leads every state; the mirror blocks stay below.
|
|
const body = (
|
|
<>
|
|
{connectionBlock}
|
|
{content}
|
|
</>
|
|
)
|
|
|
|
const description =
|
|
'Compromissos do Teqo refletem no calendário Google compartilhado em minutos, e edições feitas nele voltam para o Teqo. Quem segue recebe aviso conforme as próprias configurações.'
|
|
|
|
const handleOpenChange = (next: boolean) => {
|
|
if (!next) resetTransientState()
|
|
onOpenChange(next)
|
|
}
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
|
|
<DrawerContent className="max-h-[85vh]">
|
|
<DrawerHeader className="text-left">
|
|
<DrawerTitle>Agenda da Campanha no Google</DrawerTitle>
|
|
<DrawerDescription>{description}</DrawerDescription>
|
|
</DrawerHeader>
|
|
<div className="overflow-y-auto px-4 pb-2">
|
|
<div className="space-y-4">
|
|
{body}
|
|
{errorNotice}
|
|
{actions}
|
|
</div>
|
|
</div>
|
|
<DrawerFooter className="border-t">
|
|
<DrawerCloseButton className="w-full">Fechar</DrawerCloseButton>
|
|
</DrawerFooter>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl sm:p-0">
|
|
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
|
<DialogTitle>Agenda da Campanha no Google</DialogTitle>
|
|
<DialogDescription>{description}</DialogDescription>
|
|
</DialogHeader>
|
|
<div
|
|
data-slot="dialog-scroll-body"
|
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-4"
|
|
>
|
|
<div className="space-y-4">{body}</div>
|
|
</div>
|
|
{errorNotice || actions ? (
|
|
<div data-slot="dialog-footer" className="shrink-0 space-y-3 border-t px-6 py-4">
|
|
{errorNotice}
|
|
{actions}
|
|
</div>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|