feat: Initialize Next.js project with Shadcn UI components, Tailwind CSS, and Oracle DB integration.

This commit is contained in:
JuruSysadmin 2026-01-07 12:29:06 -03:00
parent 18c9301ae5
commit 7c613d5249
30 changed files with 9621 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

3
algua.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

24
components.json Normal file
View File

@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@kibo-ui": "https://www.kibo-ui.com/r/{name}.json"
}
}

33
eslint.config.mjs Normal file
View File

@ -0,0 +1,33 @@
import { FlatCompat } from '@eslint/eslintrc';
import { dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
...nextVitals,
{
rules: {
'react/no-unescaped-entities': 'off',
'@next/next/no-page-custom-font': 'off',
},
},
{
ignores: [
'node_modules/**',
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
],
},
];
export default eslintConfig;

19
next.config.mjs Normal file
View File

@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Next 16+: use `serverExternalPackages` instead of experimental.serverComponentsExternalPackages
serverExternalPackages: ['oracledb'],
// Add an explicit (empty) Turbopack config so Next 16 doesn't error
// when using Turbopack together with a webpack config.
turbopack: {},
webpack: (config, { isServer }) => {
if (isServer) {
config.externals = config.externals || [];
config.externals.push('oracledb');
}
return config;
},
};
export default nextConfig;

7494
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "portal-calendario-dias-rota",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start --port 3003",
"lint": "next lint"
},
"dependencies": {
"@faker-js/faker": "^10.1.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@types/oracledb": "^6.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"jotai": "^2.16.0",
"lucide-react": "^0.560.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"oracledb": "^6.9.0",
"react": "^18",
"react-dom": "^18",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "^16.1.1",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@ -0,0 +1,93 @@
'use server';
import { executeOracleUpdate } from '@/db/oracle';
import { revalidatePath, revalidateTag } from 'next/cache';
async function updateBladinho(delivery: boolean, date: Date) {
// Converte boolean para 'S' ou 'N' conforme esperado pelo Oracle
const deliveryValue = delivery ? 'N' : 'S';
// Formata a data para DD/MM/YYYY conforme exigido pelo Oracle
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const formattedDate = `${day}/${month}/${year}`;
console.log(
`🔄 Tentando atualizar: data=${formattedDate}, delivery=${deliveryValue}`
);
// Usa objeto de binds para evitar problemas com palavras reservadas
// TO_DATE converte a string DD/MM/YYYY para DATE do Oracle
const sql = `
UPDATE PCDIASUTEIS
SET DIAROTA = :deliveryVal
WHERE DATA = TO_DATE(:dataVal, 'DD/MM/YYYY') AND CODFILIAL = 12
`;
const binds = {
deliveryVal: deliveryValue,
dataVal: formattedDate,
};
console.log('📝 SQL:', sql);
console.log('📦 Binds:', JSON.stringify(binds, null, 2));
const rowsAffected = await executeOracleUpdate(sql, binds);
console.log(`✅ UPDATE concluído: ${rowsAffected} linha(s) atualizada(s)`);
if (rowsAffected === 0) {
console.warn(
'⚠️ Nenhuma linha foi atualizada. Verifique se a data existe na tabela.'
);
}
return rowsAffected;
}
async function updateBaldinhoDaysSale(daysSale: number): Promise<number> {
const sql = `UPDATE PCPARAMFILIAL SET PCPARAMFILIAL.VALOR=:daysSale WHERE NOME = 'DIAS_CORTE_LOGISTICA' AND CODFILIAL=12`;
const binds = {
daysSale: daysSale,
};
const result = await executeOracleUpdate(sql, binds);
console.log(`✅ UPDATE concluído: ${result} linha(s) atualizada(s)`);
return result;
}
async function updateBaldinhoDeliverySize(
deliverySize: number
): Promise<number> {
const sql = `UPDATE PCPARAMFILIAL SET PCPARAMFILIAL.VALOR=:deliverySize WHERE NOME = 'CAPACIDADE_LOGISTICA' AND CODFILIAL=12`;
const binds = {
deliverySize: deliverySize,
};
const result = await executeOracleUpdate(sql, binds);
console.log(`✅ UPDATE concluído: ${result} linha(s) atualizada(s)`);
return result;
}
export async function changeNoDelivery(date: Date, delivery: boolean) {
await updateBladinho(delivery, date);
console.log(
`clicado: ${date.toLocaleDateString('pt-BR')}, delivery: ${delivery}`
);
// Next 16 requires cache profile; empty config preserves current defaults
revalidateTag('baldinho', {});
revalidatePath('/dias-rota');
}
export async function changeBaldinhoDaysSale(daysSale: number) {
await updateBaldinhoDaysSale(daysSale);
console.log(`clicado: ${daysSale}`);
revalidateTag('baldinho');
revalidatePath('/dias-rota');
}
export async function changeBaldinhoDeliverySize(deliverySize: number) {
await updateBaldinhoDeliverySize(deliverySize);
console.log(`clicado: ${deliverySize}`);
revalidateTag('baldinho');
revalidatePath('/dias-rota');
}

View File

@ -0,0 +1,81 @@
import { executeOracleQuery } from '@/db/oracle';
import { unstable_cache } from 'next/cache';
export interface GetBladinhoParams {
dateDelivery: Date;
delivery: string;
deliverySize: number;
saleWeight: number;
avaliableDelivery: number;
id: number;
}
export interface GetBladinhoResponse {
dateDelivery: Date;
delivery: boolean;
deliverySize: number;
saleWeight: number;
avaliableDelivery: number;
id: number;
daysSale: number;
}
async function getBladinho(): Promise<GetBladinhoResponse[]> {
const sql = `
SELECT rownum as "id",
PCDIASUTEIS.DATA as "dateDelivery",
NVL (PCDIASUTEIS.DIAROTA, 'N') as "delivery",
CASE WHEN NVL (PCDIASUTEIS.DIAROTA, 'N') = 'N' THEN 0
ELSE (PARAMFILIAL.OBTERCOMONUMBER ('CAPACIDADE_LOGISTICA', 12)) END as "deliverySize",
ROUND ( (NVL (VENDAS.TOTPESO, 0) / 1000), 3) as "saleWeight",
CASE WHEN NVL (PCDIASUTEIS.DIAROTA, 'N') = 'N' THEN 0
ELSE ROUND (
GREATEST (
( ( PARAMFILIAL.OBTERCOMONUMBER ('CAPACIDADE_LOGISTICA',
12)
* 1000)
- NVL (VENDAS.TOTPESO, 0))
/ 1000,
0),
3) END as "avaliableDelivery",
PARAMFILIAL.OBTERCOMONUMBER ('DIAS_CORTE_LOGISTICA',12) "daysSale"
FROM PCDIASUTEIS,
( SELECT PCPEDC.DTENTREGA, SUM (PCPEDC.TOTPESO) TOTPESO
FROM PCPEDC
WHERE PCPEDC.POSICAO IN ('L', 'M')
AND PCPEDC.CONDVENDA = 8
AND PCPEDC.CODFILIAL IN (12, 13, 4, 6)
AND EXISTS
(SELECT TV7.NUMPED
FROM PCPEDC TV7
WHERE TV7.NUMPED = PCPEDC.NUMPEDENTFUT
AND TV7.POSICAO = 'F')
GROUP BY PCPEDC.DTENTREGA) VENDAS
WHERE PCDIASUTEIS.CODFILIAL = 12 --AND NVL(PCDIASUTEIS.DIAROTA,'N') = 'S'
AND PCDIASUTEIS.DATA = VENDAS.DTENTREGA(+)
ORDER BY PCDIASUTEIS.DATA
`;
const result = await executeOracleQuery(sql);
return result.map((item): GetBladinhoResponse => {
let deliveryBoolean: boolean;
if (item.delivery === 'N') {
deliveryBoolean = false;
} else {
deliveryBoolean = true;
}
return {
dateDelivery: item.dateDelivery,
delivery: deliveryBoolean,
deliverySize: item.deliverySize,
saleWeight: item.saleWeight || 0,
avaliableDelivery: item.avaliableDelivery,
id: item.id,
daysSale: item.daysSale || 0,
};
});
}
export const cachedGetBladinho = unstable_cache(getBladinho, ['bladinho'], {
revalidate: 60, // 1 minute
tags: ['baldinho'],
});

View File

@ -0,0 +1,13 @@
'use server';
import { cachedGetBladinho } from '../data-access/calendario/get-bladinho';
import Pagina from './pagina';
export default async function Page() {
const bladinho = await cachedGetBladinho();
return (
<div>
<Pagina bladinhoData={bladinho} />
</div>
);
}

View File

@ -0,0 +1,317 @@
'use client';
import {
CalendarBody,
CalendarDate,
CalendarDatePagination,
CalendarDatePicker,
CalendarHeader,
CalendarMonthPicker,
CalendarProvider,
CalendarYearPicker,
type Feature,
} from '@/components/kibo-ui/calendar';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
changeBaldinhoDaysSale,
changeBaldinhoDeliverySize,
changeNoDelivery,
} from '../action/update-baldinho';
import { type GetBladinhoResponse } from '../data-access/calendario/get-bladinho';
interface PaginaProps {
bladinhoData: GetBladinhoResponse[];
}
// Componente customizado para exibir informações em múltiplas linhas
const CustomCalendarItem = ({ feature }: { feature: Feature }) => {
const lines = feature.name.split('\n');
return (
<div className="flex items-start gap-2">
<div
className="h-2 w-2 shrink-0 rounded-full mt-1"
style={{
backgroundColor: feature.status.color,
}}
/>
<div className="flex flex-col gap-0.5 min-w-0">
{lines.map((line, index) => (
<span key={index} className="text-xs leading-tight break-words">
{line}
</span>
))}
</div>
</div>
);
};
const Pagina = ({ bladinhoData }: PaginaProps) => {
// Estado para controlar a abertura do AlertDialog
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Estado para armazenar a data e o novo valor de delivery quando o usuário clicar
const [pendingUpdate, setPendingUpdate] = useState<{
date: Date;
delivery: boolean;
} | null>(null);
// Transforma os dados do bladinho em features do calendário
const [valorBaldinho, setValorBaldinho] = useState<number | undefined>(
bladinhoData[0].deliverySize ?? 0
);
const [diasBaldinho, setDiasBaldinho] = useState<number | undefined>(
bladinhoData[0].daysSale ?? 0
);
const [isLoadingDaysSale, setIsLoadingDaysSale] = useState(false);
const [isLoadingDeliverySize, setIsLoadingDeliverySize] = useState(false);
// Estado para controlar a abertura do AlertDialog
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Estado para armazenar a data e o novo valor de delivery quando o usuário clicar
const [pendingUpdate, setPendingUpdate] = useState<{
date: Date;
delivery: boolean;
} | null>(null);
const features = useMemo<Feature[]>(() => {
return bladinhoData.map((item) => {
const dateDelivery = new Date(item.dateDelivery);
// Cria um texto com os dados adicionais em formato de array para exibir em linhas separadas
const additionalInfo = [
`Capacidade: ${item.deliverySize}`,
`Peso Venda: ${item.saleWeight}`,
`Disponível: ${item.avaliableDelivery}`,
].join('\n');
return {
id: item.id.toString(),
name: additionalInfo,
startAt: dateDelivery,
endAt: dateDelivery,
status: {
id: item.id.toString(),
name: item.delivery ? 'Delivery' : 'No Delivery',
color: item.delivery ? '#6B7280' : '#EF4444', // Cinza se delivery, vermelho se não
},
isSelected: !item.delivery, // Invertido: fundo vermelho quando delivery é false
delivery: item.delivery, // Adiciona a propriedade delivery para uso no calendário
} as Feature & { delivery: boolean };
});
}, [bladinhoData]);
// Atualiza os features quando os dados mudarem
const selectedFeatures = useMemo(() => features, [features]);
const earliestYear = useMemo(
() =>
selectedFeatures
.map((feature) => feature.startAt.getFullYear())
.sort()
.at(0) ?? new Date().getFullYear(),
[selectedFeatures]
);
const latestYear = useMemo(
() =>
selectedFeatures
.map((feature) => feature.endAt.getFullYear())
.sort()
.at(-1) ?? new Date().getFullYear(),
[selectedFeatures]
);
const handleDateClick = (date: Date, dayFeatures?: Feature[]) => {
// Encontra o feature correspondente à data clicada
const featureForDate = dayFeatures?.find((feature) => {
const featureDate = new Date(feature.startAt);
return (
featureDate.getDate() === date.getDate() &&
featureDate.getMonth() === date.getMonth() &&
featureDate.getFullYear() === date.getFullYear()
);
});
let newDelivery: boolean;
if (featureForDate) {
// Extrai o isSelected atual do feature
const currentIsSelected = featureForDate.isSelected ?? false;
// Inverte o valor para obter o novo delivery (!isSelected)
newDelivery = !currentIsSelected;
} else {
// Se não há feature para esta data, cria um novo com delivery = true
newDelivery = true;
}
// Armazena os dados e abre o dialog
setPendingUpdate({ date, delivery: newDelivery });
setIsDialogOpen(true);
};
const handleConfirmUpdate = async () => {
if (pendingUpdate) {
// Executa o update apenas quando o usuário confirmar
await changeNoDelivery(pendingUpdate.date, pendingUpdate.delivery);
// Fecha o dialog e limpa o estado
setIsDialogOpen(false);
setPendingUpdate(null);
}
};
const handleCancelUpdate = () => {
// Apenas fecha o dialog e limpa o estado, sem fazer update
setIsDialogOpen(false);
setPendingUpdate(null);
};
const handleBaldinhoDaysSaleChange = async (daysSale: number) => {
if ((daysSale ?? 0) <= 0) {
toast.error('O valor deve ser maior que zero');
return;
}
setDiasBaldinho(daysSale);
setIsLoadingDaysSale(true);
const toastId = toast.loading('Atualizando dias baldinho...');
try {
await changeBaldinhoDaysSale(daysSale);
toast.success(`Dias baldinho atualizado para ${daysSale}`, {
id: toastId,
});
} catch (error) {
toast.error('Erro ao atualizar dias baldinho', { id: toastId });
} finally {
setIsLoadingDaysSale(false);
}
// Armazena os dados e abre o dialog
setPendingUpdate({ date, delivery: newDelivery });
setIsDialogOpen(true);
};
const handleConfirmUpdate = async () => {
if (pendingUpdate) {
// Executa o update apenas quando o usuário confirmar
await changeNoDelivery(pendingUpdate.date, pendingUpdate.delivery);
// Fecha o dialog e limpa o estado
setIsDialogOpen(false);
setPendingUpdate(null);
}
};
const handleCancelUpdate = () => {
// Apenas fecha o dialog e limpa o estado, sem fazer update
setIsDialogOpen(false);
setPendingUpdate(null);
};
const handleBaldinhoDeliverySizeChange = async (deliverySize: number) => {
if ((deliverySize ?? 0) <= 0) {
toast.error('O valor deve ser maior que zero');
return;
}
setValorBaldinho(deliverySize);
setIsLoadingDeliverySize(true);
const toastId = toast.loading('Atualizando capacidade baldinho...');
try {
await changeBaldinhoDeliverySize(deliverySize);
toast.success(`Capacidade baldinho atualizada para ${deliverySize}`, {
id: toastId,
});
} catch (error) {
toast.error('Erro ao atualizar capacidade baldinho', { id: toastId });
} finally {
setIsLoadingDeliverySize(false);
}
};
return (
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<CalendarProvider>
<CalendarDate>
<CalendarDatePicker>
<div className="flex flex-row gap-2">
<CalendarMonthPicker />
<CalendarYearPicker end={latestYear} start={earliestYear} />
<div className="flex flex-row gap-2 items-center">
<Label className="text-sm min-w-32">Capacidade</Label>
<Input
type="number"
value={valorBaldinho}
onChange={(e) => setValorBaldinho(Number(e.target.value))}
/>
<Button
type="button"
onClick={() =>
handleBaldinhoDeliverySizeChange(valorBaldinho ?? 0)
}
disabled={isLoadingDeliverySize}
>
{isLoadingDeliverySize ? 'Salvando...' : 'Salvar'}
</Button>
</div>
<div className="flex flex-row gap-2 items-center">
<Label className="text-sm min-w-32">Dias úteis</Label>
<Input
type="number"
value={diasBaldinho}
onChange={(e) => setDiasBaldinho(Number(e.target.value))}
/>
<Button
type="button"
onClick={() =>
handleBaldinhoDaysSaleChange(diasBaldinho ?? 0)
}
disabled={isLoadingDaysSale}
>
{isLoadingDaysSale ? 'Salvando...' : 'Salvar'}
</Button>
</div>
</div>
</CalendarDatePicker>
<CalendarDatePagination />
</CalendarDate>
<CalendarHeader />
<CalendarBody features={selectedFeatures} onDateClick={handleDateClick}>
{({ feature }) => (
<CustomCalendarItem feature={feature} key={feature.id} />
)}
</CalendarBody>
</CalendarProvider>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar alteração</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja alterar o status de entrega para esta data?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelUpdate}>
Cancelar
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmUpdate}>
Confirmar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default Pagina;

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

BIN
src/app/fonts/GeistVF.woff Normal file

Binary file not shown.

75
src/app/globals.css Normal file
View File

@ -0,0 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 72.2% 50.6%;
--primary-foreground: 0 85.7% 97.3%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 72.2% 50.6%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 72.2% 50.6%;
--primary-foreground: 0 85.7% 97.3%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 72.2% 50.6%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

37
src/app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster />
</body>
</html>
);
}

View File

@ -0,0 +1,522 @@
'use client';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { getDay, getDaysInMonth, isSameDay } from 'date-fns';
import { atom, useAtom } from 'jotai';
import {
Check,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsUpDown,
} from 'lucide-react';
import {
createContext,
memo,
type ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
export type CalendarState = {
month: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
year: number;
};
const monthAtom = atom<CalendarState['month']>(
new Date().getMonth() as CalendarState['month']
);
const yearAtom = atom<CalendarState['year']>(new Date().getFullYear());
export const useCalendarMonth = () => useAtom(monthAtom);
export const useCalendarYear = () => useAtom(yearAtom);
type CalendarContextProps = {
locale: Intl.LocalesArgument;
startDay: number;
};
const CalendarContext = createContext<CalendarContextProps>({
locale: 'pt-BR',
startDay: 0,
});
export type Status = {
id: string;
name: string;
color: string;
};
export type Feature = {
id: string;
name: string;
startAt: Date;
endAt: Date;
status: Status;
isSelected?: boolean;
};
type ComboboxProps = {
value: string;
setValue: (value: string) => void;
data: {
value: string;
label: string;
}[];
labels: {
button: string;
empty: string;
search: string;
};
className?: string;
};
export const monthsForLocale = (
localeName: Intl.LocalesArgument,
monthFormat: Intl.DateTimeFormatOptions['month'] = 'long'
) => {
const format = new Intl.DateTimeFormat(localeName, { month: monthFormat })
.format;
return Array.from({ length: 12 }, (_, m) =>
format(new Date(Date.UTC(2021, m, 2)))
);
};
export const daysForLocale = (
locale: Intl.LocalesArgument,
startDay: number
) => {
const weekdays: string[] = [];
const baseDate = new Date(2024, 0, startDay);
for (let i = 0; i < 7; i++) {
weekdays.push(
new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(baseDate)
);
baseDate.setDate(baseDate.getDate() + 1);
}
return weekdays;
};
const Combobox = ({
value,
setValue,
data,
labels,
className,
}: ComboboxProps) => {
const [open, setOpen] = useState(false);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className={cn('w-40 justify-between capitalize', className)}
variant="outline"
>
{value
? data.find((item) => item.value === value)?.label
: labels.button}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-0">
<Command
filter={(value, search) => {
const label = data.find((item) => item.value === value)?.label;
return label?.toLowerCase().includes(search.toLowerCase()) ? 1 : 0;
}}
>
<CommandInput placeholder={labels.search} />
<CommandList>
<CommandEmpty>{labels.empty}</CommandEmpty>
<CommandGroup>
{data.map((item) => (
<CommandItem
className="capitalize"
key={item.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? '' : currentValue);
setOpen(false);
}}
value={item.value}
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === item.value ? 'opacity-100' : 'opacity-0'
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
type OutOfBoundsDayProps = {
day: number;
};
const OutOfBoundsDay = ({ day }: OutOfBoundsDayProps) => (
<div className="relative h-full w-full bg-secondary p-1 text-muted-foreground text-xs">
{day}
</div>
);
export type CalendarBodyProps = {
features: Feature[];
children: (props: { feature: Feature }) => ReactNode;
onDateClick?: (date: Date, dayFeatures?: Feature[]) => void;
};
export const CalendarBody = ({
features,
children,
onDateClick,
}: CalendarBodyProps) => {
const [month] = useCalendarMonth();
const [year] = useCalendarYear();
const { startDay } = useContext(CalendarContext);
// Memoize expensive date calculations
const currentMonthDate = useMemo(
() => new Date(year, month, 1),
[year, month]
);
const daysInMonth = useMemo(
() => getDaysInMonth(currentMonthDate),
[currentMonthDate]
);
const firstDay = useMemo(
() => (getDay(currentMonthDate) - startDay + 7) % 7,
[currentMonthDate, startDay]
);
// Memoize previous month calculations
const prevMonthData = useMemo(() => {
const prevMonth = month === 0 ? 11 : month - 1;
const prevMonthYear = month === 0 ? year - 1 : year;
const prevMonthDays = getDaysInMonth(new Date(prevMonthYear, prevMonth, 1));
const prevMonthDaysArray = Array.from(
{ length: prevMonthDays },
(_, i) => i + 1
);
return { prevMonthDays, prevMonthDaysArray };
}, [month, year]);
// Memoize next month calculations
const nextMonthData = useMemo(() => {
const nextMonth = month === 11 ? 0 : month + 1;
const nextMonthYear = month === 11 ? year + 1 : year;
const nextMonthDays = getDaysInMonth(new Date(nextMonthYear, nextMonth, 1));
const nextMonthDaysArray = Array.from(
{ length: nextMonthDays },
(_, i) => i + 1
);
return { nextMonthDaysArray };
}, [month, year]);
// Memoize features filtering by day to avoid recalculating on every render
const featuresByDay = useMemo(() => {
const result: { [day: number]: Feature[] } = {};
for (let day = 1; day <= daysInMonth; day++) {
result[day] = features.filter((feature) => {
return isSameDay(new Date(feature.endAt), new Date(year, month, day));
});
}
return result;
}, [features, daysInMonth, year, month]);
const days: ReactNode[] = [];
for (let i = 0; i < firstDay; i++) {
const day =
prevMonthData.prevMonthDaysArray[
prevMonthData.prevMonthDays - firstDay + i
];
if (day) {
days.push(<OutOfBoundsDay day={day} key={`prev-${i}`} />);
}
}
for (let day = 1; day <= daysInMonth; day++) {
const featuresForDay = featuresByDay[day] || [];
const dayDate = new Date(year, month, day);
const isToday = isSameDay(dayDate, new Date());
const hasSelectedFeature = featuresForDay.some(
(feature) => feature.isSelected === true
);
days.push(
<div
className={cn(
'relative flex h-full w-full flex-col gap-1 p-1 text-muted-foreground text-xs cursor-pointer transition-colors',
hasSelectedFeature && 'bg-red-500/20',
'hover:bg-primary/20 hover:text-primary'
)}
key={day}
onClick={() => {
onDateClick?.(dayDate, featuresForDay);
}}
>
<span
className={cn(
'inline-flex items-center justify-center w-6 h-6 rounded-full',
isToday &&
'ring-2 ring-blue-500 bg-blue-500/10 font-semibold text-blue-700'
)}
>
{day}
</span>
<div>
{featuresForDay.slice(0, 3).map((feature) => children({ feature }))}
</div>
{featuresForDay.length > 3 && (
<span className="block text-muted-foreground text-xs">
+{featuresForDay.length - 3} more
</span>
)}
</div>
);
}
const remainingDays = 7 - ((firstDay + daysInMonth) % 7);
if (remainingDays < 7) {
for (let i = 0; i < remainingDays; i++) {
const day = nextMonthData.nextMonthDaysArray[i];
if (day) {
days.push(<OutOfBoundsDay day={day} key={`next-${i}`} />);
}
}
}
return (
<div className="grid flex-grow grid-cols-7">
{days.map((day, index) => (
<div
className={cn(
'relative aspect-square overflow-hidden border-t border-r',
index % 7 === 6 && 'border-r-0'
)}
key={index}
>
{day}
</div>
))}
</div>
);
};
export type CalendarDatePickerProps = {
className?: string;
children: ReactNode;
};
export const CalendarDatePicker = ({
className,
children,
}: CalendarDatePickerProps) => (
<div className={cn('flex items-center gap-1', className)}>{children}</div>
);
export type CalendarMonthPickerProps = {
className?: string;
};
export const CalendarMonthPicker = ({
className,
}: CalendarMonthPickerProps) => {
const [month, setMonth] = useCalendarMonth();
const { locale } = useContext(CalendarContext);
// Memoize month data to avoid recalculating date formatting
const monthData = useMemo(() => {
return monthsForLocale(locale).map((month, index) => ({
value: index.toString(),
label: month,
}));
}, [locale]);
return (
<Combobox
className={className}
data={monthData}
labels={{
button: 'Select month',
empty: 'No month found',
search: 'Search month',
}}
setValue={(value) =>
setMonth(Number.parseInt(value, 10) as CalendarState['month'])
}
value={month.toString()}
/>
);
};
export type CalendarYearPickerProps = {
className?: string;
start: number;
end: number;
};
export const CalendarYearPicker = ({
className,
start,
end,
}: CalendarYearPickerProps) => {
const [year, setYear] = useCalendarYear();
return (
<Combobox
className={className}
data={Array.from({ length: end - start + 1 }, (_, i) => ({
value: (start + i).toString(),
label: (start + i).toString(),
}))}
labels={{
button: 'Select year',
empty: 'No year found',
search: 'Search year',
}}
setValue={(value) => setYear(Number.parseInt(value, 10))}
value={year.toString()}
/>
);
};
export type CalendarDatePaginationProps = {
className?: string;
};
export const CalendarDatePagination = ({
className,
}: CalendarDatePaginationProps) => {
const [month, setMonth] = useCalendarMonth();
const [year, setYear] = useCalendarYear();
const handlePreviousMonth = useCallback(() => {
if (month === 0) {
setMonth(11);
setYear(year - 1);
} else {
setMonth((month - 1) as CalendarState['month']);
}
}, [month, year, setMonth, setYear]);
const handleNextMonth = useCallback(() => {
if (month === 11) {
setMonth(0);
setYear(year + 1);
} else {
setMonth((month + 1) as CalendarState['month']);
}
}, [month, year, setMonth, setYear]);
return (
<div className={cn('flex items-center gap-2', className)}>
<Button onClick={handlePreviousMonth} size="icon" variant="ghost">
<ChevronLeftIcon size={16} />
</Button>
<Button onClick={handleNextMonth} size="icon" variant="ghost">
<ChevronRightIcon size={16} />
</Button>
</div>
);
};
export type CalendarDateProps = {
children: ReactNode;
};
export const CalendarDate = ({ children }: CalendarDateProps) => (
<div className="flex items-center justify-between p-3">{children}</div>
);
export type CalendarHeaderProps = {
className?: string;
};
export const CalendarHeader = ({ className }: CalendarHeaderProps) => {
const { locale, startDay } = useContext(CalendarContext);
// Memoize days data to avoid recalculating date formatting
const daysData = useMemo(() => {
return daysForLocale(locale, startDay);
}, [locale, startDay]);
return (
<div className={cn('grid flex-grow grid-cols-7', className)}>
{daysData.map((day) => (
<div className="p-3 text-right text-muted-foreground text-xs" key={day}>
{day}
</div>
))}
</div>
);
};
export type CalendarItemProps = {
feature: Feature;
className?: string;
};
export const CalendarItem = memo(
({ feature, className }: CalendarItemProps) => (
<div className={cn('flex items-center gap-2', className)}>
<div
className="h-2 w-2 shrink-0 rounded-full"
style={{
backgroundColor: feature.status.color,
}}
/>
<span className="truncate">{feature.name}</span>
</div>
)
);
CalendarItem.displayName = 'CalendarItem';
export type CalendarProviderProps = {
locale?: Intl.LocalesArgument;
startDay?: number;
children: ReactNode;
className?: string;
};
export const CalendarProvider = ({
locale = 'pt-BR',
startDay = 0,
children,
className,
}: CalendarProviderProps) => (
<CalendarContext.Provider value={{ locale, startDay }}>
<div className={cn('relative flex flex-col', className)}>{children}</div>
</CalendarContext.Provider>
);

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,46 @@
"use client"
import {
CircleCheck,
Info,
LoaderCircle,
OctagonX,
TriangleAlert,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
position="top-center"
className="toaster group"
icons={{
success: <CircleCheck className="h-4 w-4" />,
info: <Info className="h-4 w-4" />,
warning: <TriangleAlert className="h-4 w-4" />,
error: <OctagonX className="h-4 w-4" />,
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
}}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

113
src/db/oracle.ts Normal file
View File

@ -0,0 +1,113 @@
import 'dotenv/config';
import oracledb from 'oracledb';
// Usar modo Thin (padrão no node-oracledb 6.x)
// O modo Thin não requer binários nativos e funciona melhor no Next.js
// Se precisar usar Thick mode, configure ORACLE_LIB_DIR e descomente abaixo:
if (process.env.ORACLE_LIB_DIR) {
oracledb.initOracleClient({ libDir: process.env.ORACLE_LIB_DIR });
}
// Configuração do pool de conexões Oracle
const oracleConfig = {
user: process.env.ORACLE_USER,
password: process.env.ORACLE_PASSWORD,
connectString: process.env.ORACLE_CONNECTION_STRING,
poolMin: Number(process.env.ORACLE_POOL_MIN) || 1,
poolMax: Number(process.env.ORACLE_POOL_MAX) || 30,
poolIncrement: Number(process.env.ORACLE_POOL_INCREMENT) || 1,
queueTimeout: Number(process.env.ORACLE_QUEUE_TIMEOUT) || 60000,
poolTimeout: Number(process.env.ORACLE_INACTIVITY_TIMEOUT) || 20000,
};
// Criar pool de conexões
let pool: oracledb.Pool | null = null;
export async function getOraclePool(): Promise<oracledb.Pool> {
if (!pool) {
try {
pool = await oracledb.createPool(oracleConfig);
console.log('✅ Pool Oracle criado com sucesso');
} catch (error) {
console.error('❌ Erro ao criar pool Oracle:', error);
throw error;
}
}
return pool;
}
export async function executeOracleQuery(
sql: string,
binds: any[] | Record<string, any> = []
): Promise<any[]> {
const pool = await getOraclePool();
let connection: oracledb.Connection | null = null;
try {
connection = await pool.getConnection();
const result = await connection.execute(sql, binds, {
outFormat: oracledb.OUT_FORMAT_OBJECT,
autoCommit: true, // Commit automático
});
return result.rows || [];
} catch (error) {
console.error('❌ Erro ao executar query Oracle:', error);
throw error;
} finally {
if (connection) {
await connection.close();
}
}
}
export async function executeOracleUpdate(
sql: string,
binds: any[] | Record<string, any> = []
): Promise<number> {
const pool = await getOraclePool();
let connection: oracledb.Connection | null = null;
try {
connection = await pool.getConnection();
console.log(
'🔍 Executando UPDATE com binds:',
JSON.stringify(binds, null, 2)
);
const result = await connection.execute(sql, binds, {
autoCommit: true, // Commit automático para UPDATE
});
const rowsAffected = result.rowsAffected || 0;
console.log(`✅ UPDATE executado: ${rowsAffected} linha(s) afetada(s)`);
if (rowsAffected === 0) {
console.warn(
'⚠️ ATENÇÃO: Nenhuma linha foi afetada pelo UPDATE. Verifique:'
);
console.warn(' - Se a data existe na tabela PCDIASUTEIS');
console.warn(' - Se CODFILIAL = 12 corresponde ao registro');
console.warn(' - Se o formato da data está correto');
}
return rowsAffected;
} catch (error) {
console.error('❌ Erro ao executar UPDATE Oracle:', error);
throw error;
} finally {
if (connection) {
await connection.close();
}
}
}
export async function closeOraclePool(): Promise<void> {
if (pool) {
await pool.close();
pool = null;
console.log('✅ Pool Oracle fechado');
}
}
export default {
getOraclePool,
executeOracleQuery,
executeOracleUpdate,
closeOraclePool,
};

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

63
tailwind.config.ts Normal file
View File

@ -0,0 +1,63 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))'
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
plugins: [require("tailwindcss-animate")],
};
export default config;

41
tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}