first commit
All checks were successful
CI / backend (push) Successful in 13m19s
CI / frontend (push) Successful in 11m3s
CI / docker (push) Successful in 1m58s

This commit is contained in:
Cauê Faleiros
2026-06-03 16:31:42 -03:00
commit 8c7e5fbbe4
92 changed files with 18226 additions and 0 deletions

40
.env.example Normal file
View File

@@ -0,0 +1,40 @@
APP_ENV=production
APP_PUBLIC_URL=https://app.example.com.br
APP_TIMEZONE=America/Sao_Paulo
HTTP_ADDR=:8080
LOG_LEVEL=info
BACKEND_PORT=8080
FRONTEND_PORT=5173
SUPER_ADMIN_EMAIL=admin@example.com.br
SUPER_ADMIN_PASSWORD=change-me-secure-password
POSTGRES_DB=mira
POSTGRES_USER=mira
POSTGRES_PASSWORD=change-me-secure-password
DATABASE_URL=postgres://mira:change-me-secure-password@postgres:5432/mira?sslmode=disable
JWT_SECRET=change-me-with-a-long-random-secret
JWT_ACCESS_TOKEN_TTL_MINUTES=15
JWT_REFRESH_TOKEN_TTL_DAYS=7
CORS_ALLOWED_ORIGINS=https://app.example.com.br
CALENDAR_PROVIDER=brasilapi
CALENDAR_API_BASE_URL=https://brasilapi.com.br
CALENDAR_API_KEY=
CALENDAR_CACHE_TTL_HOURS=24
SMTP_HOST=smtp.example.com.br
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=no-reply@example.com.br
SMTP_FROM_NAME=Mira
UPLOAD_MAX_SIZE_MB=10
UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf
STORAGE_DRIVER=local
STORAGE_LOCAL_PATH=/var/lib/mira/uploads
VITE_API_BASE_URL=http://localhost:8080/api/v1

52
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,52 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Download dependencies
working-directory: backend
run: go mod download
- name: Vet
working-directory: backend
run: go vet ./...
- name: Test
working-directory: backend
run: go test ./...
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm install
- name: Build
working-directory: frontend
run: npm run build
docker:
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@v4
- name: Build backend image
run: docker build ./backend
- name: Build frontend image
run: docker build ./frontend --build-arg VITE_API_BASE_URL=http://localhost:8080/api/v1

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
.env
.env.*
!.env.example
node_modules/
dist/
.vite/
coverage/
.DS_Store
*.log
/frontend/node_modules/
/frontend/dist/
/frontend/.vite/
/backend/tmp/
/new_frontend/

653
AGENT.md Normal file
View File

@@ -0,0 +1,653 @@
# AGENT.md
## Overview
This project is a mobile-first web application for an advertising agency to organize editorial calendars, important dates, deliveries, assets, and client-facing planning.
The main goal is to replace the current messy Trello workflow with a centralized, secure, responsive application where:
- agency staff can view and manage all clients they are allowed to access;
- each client can access only their own area;
- the calendar shows Brazilian national holidays, commemorative dates, and custom dates;
- specific days can contain content such as post copy, images, notes, status, and attachments;
- the dashboard includes a weekly overview so the team can quickly understand what is done and what is still pending.
The application itself must be fully localized in Brazilian Portuguese (`pt-BR`). Project documentation and developer-facing instructions may be written in English unless explicitly requested otherwise.
## Audience
- Internal users: advertising team, social media team, account managers, designers, managers, and administrators.
- External users: agency clients, initially with read-only access.
- Product tone: clear, professional, direct, and operational.
- UI language: Brazilian Portuguese (`pt-BR`).
## Technical Stack
### Frontend
- JavaScript.
- React 19.
- shadcn/ui.
- Mobile-first and fully responsive.
- UI text in `pt-BR`.
- Accessible components, including keyboard navigation where applicable.
### Backend
- Go 1.25.
- Gin.
- REST API.
- Strong input validation.
- Authentication and role-based authorization.
- Clean, readable, modular structure.
### Infrastructure
- Repository: Gitea.
- Hosting: Portainer.
- Configuration: environment variables only.
- Delivery: Dockerfile, Docker Compose, and CI/CD pipelines.
## Product Identity
- Project name: `Mira`.
- Main brand color: orange.
- The UI must support light and dark themes.
- Theme switching must be available in the authenticated application shell.
- The design should feel operational and polished, not like a marketing landing page.
## User Roles
### Super Admin
The main administrative user for the agency.
- Created from Portainer environment variables.
- Can access all clients.
- Can create, edit, and remove users.
- Can create email invitations.
- Can manage clients, calendars, custom dates, and content.
- Can grant access to internal users and clients.
Expected variables:
- `SUPER_ADMIN_EMAIL`
- `SUPER_ADMIN_PASSWORD`
### Agency User
Internal operational user.
- Can view assigned clients.
- Can create and edit calendar content according to permissions.
- Can view the weekly dashboard.
- Can attach assets to calendar days.
- Can update delivery/content statuses.
### Client Viewer
External user linked to a specific client.
- Can log in.
- Can view only their own client area.
- Cannot create, edit, or delete content in the first version.
- Cannot access other clients.
## Authentication and Invitations
- The application must have a login screen.
- There must be no public registration screen.
- The super admin can create users and send email invitations.
- Invitations must use a unique token, expiration date, and single-use flow.
- Passwords must be stored with a strong hash, never as plaintext.
- Password reset, if implemented, must use temporary tokens.
- Sessions must expire and be revocable.
## Permissions Model
Use role-based access control with client scoping.
Minimum roles:
- `super_admin`
- `agency_user`
- `client_viewer`
Minimum rules:
- `super_admin` can access everything.
- `agency_user` can access only assigned clients unless explicitly elevated.
- `client_viewer` can access only their linked client.
- Every sensitive route must validate authentication, role, and client scope.
- Never rely on frontend-only access control.
## Core Modules
### Login
- Simple, secure, responsive screen.
- Fields: email and password.
- Error messages in Portuguese.
- Brute-force protection.
- Do not reveal whether an email exists.
### Weekly Dashboard
General view of the current week.
It should show:
- planned content and commitments for the week;
- pending items;
- completed items;
- relevant holidays and commemorative dates;
- filters by client, status, and owner;
- clear highlight for today.
### Client Calendar
The core product surface.
It should support:
- monthly view;
- weekly view;
- day detail view;
- Brazilian national holidays;
- Brazilian commemorative dates;
- client-specific custom dates;
- content/posts attached to a date;
- attachments such as images, files, and text;
- content status.
### Day Content
Each day can contain one or more items.
Suggested fields:
- title;
- description;
- content type;
- status;
- owner;
- client;
- date;
- optional time;
- copy/text;
- images and attachments;
- internal notes;
- client-visible notes;
- change history.
Suggested statuses:
- `rascunho`
- `planejado`
- `em_producao`
- `em_revisao`
- `aprovado`
- `publicado`
- `cancelado`
Keep status values compatible with Portuguese UI labels.
### Custom Dates
Allow custom dates globally or per client.
Examples:
- company anniversary;
- store anniversary;
- institutional date;
- recurring campaign;
- commercial milestone;
- internal event.
Suggested fields:
- name;
- description;
- date;
- annual recurrence flag;
- optional client;
- visibility;
- color or category.
### Clients
Each client has their own area.
Suggested fields:
- name;
- slug;
- status;
- contacts;
- linked users;
- calendar settings;
- custom dates;
- display preferences.
## Brazilian Calendar
The application must support:
- Brazilian national holidays;
- Brazilian commemorative dates;
- internal custom dates;
- client-specific custom dates.
Recommended implementation:
- keep an internal table/cache for holidays and commemorative dates;
- sync holidays by year from a configurable external provider;
- allow manual fallback if the external API fails;
- separate official holidays from non-official commemorative dates;
- allow administrative review of imported dates.
Provider decision:
- Initial provider: BrasilAPI.
- Regional holidays, municipal holidays, client-specific dates, and agency-specific commemorative dates will be added manually through the application.
- The backend must still use a provider abstraction so another provider can be added later without rewriting calendar features.
Providers considered:
- BrasilAPI: Brazilian public API with national holiday endpoints.
- Nager.Date: international public holiday API by country, with public endpoints that do not require an API key.
- Feriados API or feriados.dev: Brazil-focused services with national, state, and city coverage.
- Calendarific: international paid/freemium API with support for countries and regions.
Initial recommendation:
- implement a backend `CalendarProvider` abstraction;
- configure the provider through `CALENDAR_PROVIDER`, defaulting to `brasilapi`;
- persist provider results in the database;
- allow manual registration of commemorative dates;
- never depend on real-time external API availability to render the calendar.
Expected variables:
- `CALENDAR_PROVIDER`
- `CALENDAR_API_BASE_URL`
- `CALENDAR_API_KEY`
- `CALENDAR_CACHE_TTL_HOURS`
## Security
The project must strictly follow OWASP best practices.
Minimum requirements:
- validate and sanitize all inputs;
- protect against XSS;
- protect against CSRF where applicable;
- protect against SQL injection using parameterized queries or safe ORM patterns;
- enforce backend access control on all protected routes;
- use strong password hashing;
- use secure cookies if cookie-based sessions are used;
- use restrictive CORS;
- rate-limit login and sensitive routes;
- set HTTP security headers;
- avoid sensitive data in logs;
- handle uploads securely;
- limit file size;
- validate MIME type and extension;
- store attachments safely;
- audit administrative actions;
- use environment variables for secrets;
- never commit credentials.
OWASP references to consider during implementation:
- OWASP Top 10.
- OWASP ASVS.
- OWASP Cheat Sheet Series.
## Suggested Repository Structure
```text
.
|-- AGENT.md
|-- README.md
|-- docker-compose.yml
|-- .env.example
|-- .gitea
| `-- workflows
| `-- ci.yml
|-- frontend
| |-- Dockerfile
| |-- package.json
| |-- src
| | |-- app
| | |-- components
| | |-- features
| | |-- lib
| | |-- routes
| | `-- styles
| `-- public
|-- backend
| |-- Dockerfile
| |-- go.mod
| |-- cmd
| | `-- api
| | `-- main.go
| |-- internal
| | |-- auth
| | |-- calendar
| | |-- clients
| | |-- config
| | |-- content
| | |-- database
| | |-- mail
| | |-- users
| | `-- security
| `-- migrations
`-- docs
|-- architecture.md
|-- security.md
`-- api.md
```
## Database
Database decision: PostgreSQL.
PostgreSQL is the correct fit because the application is relational by nature: users, clients, permissions, invitations, calendar items, attachments, statuses, and audit logs all need strong consistency and queryable relationships.
Initial entities:
- users;
- clients;
- client_users;
- invitations;
- calendar_dates;
- holidays;
- commemorative_dates;
- calendar_items;
- calendar_item_attachments;
- audit_logs;
- sessions or refresh_tokens, depending on authentication strategy.
Use consistent timestamps:
- `created_at`
- `updated_at`
- `deleted_at` when soft delete is needed.
Timezone rules:
- handle dates and times carefully;
- use `America/Sao_Paulo` as the default operational timezone;
- store instants in UTC when time is involved;
- store calendar-only dates as `date`, not timestamp.
## Uploads and Attachments
Attachments are a critical security surface.
Requirements:
- limit file size;
- restrict allowed file types;
- validate actual file content;
- rename files in storage;
- avoid serving uploads directly from the same domain without protection;
- prevent execution of uploaded files;
- store metadata in the database;
- support logical deletion.
Expected variables:
- `UPLOAD_MAX_SIZE_MB`
- `UPLOAD_ALLOWED_MIME_TYPES`
- `STORAGE_DRIVER`
- `STORAGE_LOCAL_PATH`
- `STORAGE_S3_ENDPOINT`
- `STORAGE_S3_BUCKET`
- `STORAGE_S3_ACCESS_KEY`
- `STORAGE_S3_SECRET_KEY`
## Email
Used for invitations and possibly password reset.
Expected variables:
- `SMTP_HOST`
- `SMTP_PORT`
- `SMTP_USER`
- `SMTP_PASSWORD`
- `SMTP_FROM_EMAIL`
- `SMTP_FROM_NAME`
- `APP_PUBLIC_URL`
## Minimum Environment Variables
```env
APP_ENV=production
APP_PUBLIC_URL=https://app.example.com.br
APP_TIMEZONE=America/Sao_Paulo
SUPER_ADMIN_EMAIL=admin@example.com.br
SUPER_ADMIN_PASSWORD=change-me-secure-password
DATABASE_URL=postgres://user:change-me-secure-password@postgres:5432/mira?sslmode=disable
JWT_SECRET=change-me-with-a-long-random-secret
JWT_ACCESS_TOKEN_TTL_MINUTES=15
JWT_REFRESH_TOKEN_TTL_DAYS=7
CORS_ALLOWED_ORIGINS=https://app.example.com.br
CALENDAR_PROVIDER=brasilapi
CALENDAR_API_BASE_URL=https://brasilapi.com.br
CALENDAR_API_KEY=
CALENDAR_CACHE_TTL_HOURS=24
SMTP_HOST=smtp.example.com.br
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=no-reply@example.com.br
SMTP_FROM_NAME=Mira
UPLOAD_MAX_SIZE_MB=10
UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf
STORAGE_DRIVER=local
STORAGE_LOCAL_PATH=/var/lib/mira/uploads
```
## Backend API
Standards:
- versioned routes under `/api/v1`;
- consistent JSON responses;
- errors in `pt-BR`;
- payload validation before business logic;
- authentication middleware;
- authorization middleware;
- rate limit middleware;
- security headers middleware;
- structured logs.
Suggested initial routes:
```text
POST /api/v1/auth/login
POST /api/v1/auth/logout
POST /api/v1/auth/refresh
GET /api/v1/me
GET /api/v1/clients
POST /api/v1/clients
GET /api/v1/clients/:clientId
PATCH /api/v1/clients/:clientId
GET /api/v1/calendar/week
GET /api/v1/clients/:clientId/calendar
GET /api/v1/clients/:clientId/calendar/days/:date
POST /api/v1/clients/:clientId/calendar-items
GET /api/v1/calendar-items/:itemId
PATCH /api/v1/calendar-items/:itemId
DELETE /api/v1/calendar-items/:itemId
POST /api/v1/calendar-items/:itemId/attachments
DELETE /api/v1/attachments/:attachmentId
GET /api/v1/calendar/holidays
POST /api/v1/commemorative-dates
PATCH /api/v1/commemorative-dates/:dateId
DELETE /api/v1/commemorative-dates/:dateId
GET /api/v1/users
POST /api/v1/users/invitations
POST /api/v1/invitations/:token/accept
```
## Frontend
Principles:
- mobile-first;
- dense, clear, operational screens;
- no landing page;
- first screen after login should be the weekly dashboard;
- calendar must be easy to navigate on mobile;
- use shadcn/ui consistently;
- keep visible UI text in `pt-BR`;
- do not rely on color alone to communicate status;
- support loading, error, empty, and success states.
Initial screens:
- login;
- weekly dashboard;
- client list;
- client calendar;
- day detail;
- calendar item detail/create/edit;
- users and invitations;
- custom date settings.
## Gitea CI/CD
Minimum pipeline:
- frontend lint;
- frontend tests;
- frontend build;
- backend lint/vet;
- backend tests;
- backend build;
- Docker image builds;
- vulnerability checks where possible.
Expected file:
- `.gitea/workflows/ci.yml`
## Docker and Portainer
Expected deliverables:
- `frontend/Dockerfile`
- `backend/Dockerfile`
- `docker-compose.yml`
- `.env.example`
Docker Compose should include:
- frontend;
- backend;
- PostgreSQL;
- database volume;
- local upload volume when `STORAGE_DRIVER=local`.
All configuration must come from environment variables to support Portainer-based operation.
## Testing
Backend:
- business rule unit tests;
- authorization tests;
- calendar tests;
- invitation tests;
- validation tests;
- upload tests.
Frontend:
- tests for critical components;
- calendar rendering tests;
- login flow tests;
- role-based visibility tests.
E2E, when possible:
- super admin login;
- client creation;
- user invitation;
- client read-only access;
- calendar item creation;
- weekly view.
## MVP Acceptance Criteria
The MVP must allow:
- login with the super admin loaded from environment variables;
- client creation;
- internal and client users created through invitations;
- role-based access control;
- clients to view only their own page;
- agency users to view all permitted clients;
- monthly calendar per client;
- general weekly dashboard;
- Brazilian national holiday display;
- custom date registration;
- content creation per day;
- attachment of allowed images or files;
- responsive mobile UI;
- Docker Compose deployment through Portainer;
- basic Gitea CI/CD pipeline.
## Out of Scope for the Initial MVP
These can be planned after the MVP:
- public registration;
- client editing permissions;
- formal approval workflows;
- direct Instagram, Facebook, TikTok, or LinkedIn integration;
- automated publishing;
- real-time comments;
- push notifications;
- full kanban board;
- complex subscription-based multi-tenancy.
## Implementation Principles
- Prioritize clarity and security.
- Keep frontend, backend, and infrastructure separated.
- Avoid hard coupling to a single calendar provider.
- Protect all backend routes.
- Log important administrative actions.
- Create reusable components only when there is a real need.
- Prefer clear names over premature abstractions.
- Document relevant technical decisions in `docs/architecture.md`.
- Never commit secrets.
## Calendar References
- [BrasilAPI](https://brasilapi.com.br/)
- [Nager.Date](https://date.nager.at/)
- [Feriados API](https://feriadosapi.com/docs)
- [feriados.dev](https://feriados.dev/documentacao)
- [Calendarific](https://calendarific.com/)

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# Mira
Mobile-first editorial calendar application for an advertising agency and its clients.
The application UI must be localized in Brazilian Portuguese (`pt-BR`), while developer documentation can stay in English.
## Stack
- Frontend: React 19, JavaScript, shadcn/ui direction, CSS variables for light/dark themes.
- Backend: Go 1.25, Gin.
- Database: PostgreSQL.
- Calendar provider: BrasilAPI for Brazilian national holidays.
- Deployment: Docker Compose/Portainer.
- Repository and CI/CD: Gitea.
## Local Start
1. Copy `.env.example` to `.env`.
2. Replace secrets and passwords.
3. Start the stack:
```sh
docker compose up --build
```
Default local URLs:
- Frontend: `http://localhost:5173`
- Backend health: `http://localhost:8080/healthz`
- Backend API: `http://localhost:8080/api/v1`
## Current Status
This is the initial scaffold. The structure is ready for MVP implementation, but authentication, persistence, invitations, and calendar item CRUD are still placeholders.
## Key Decisions
- Use PostgreSQL, not NoSQL.
- Use BrasilAPI as the initial holiday provider.
- Add regional holidays and agency/client-specific commemorative dates manually through the app.
- Keep a backend calendar provider abstraction for future provider changes.
- Keep uploaded files protected.
- Include audit logs from the beginning of the real MVP implementation.
- Use orange as the main color and support light/dark themes.

3
backend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git
tmp
*.log

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM golang:1.25-alpine AS build
WORKDIR /app
COPY go.mod ./
RUN apk add --no-cache build-base
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/mira-api ./cmd/api
FROM alpine:3.21
RUN addgroup -S mira && adduser -S mira -G mira
WORKDIR /app
COPY --from=build /bin/mira-api /usr/local/bin/mira-api
COPY --from=build /app/migrations /app/migrations
RUN mkdir -p /var/lib/mira/uploads && chown -R mira:mira /var/lib/mira
USER mira
ENV GODEBUG=netdns=cgo
EXPOSE 8080
CMD ["mira-api"]

55
backend/cmd/api/main.go Normal file
View File

@@ -0,0 +1,55 @@
package main
import (
"context"
"log/slog"
"os"
"time"
"mira/backend/internal/auth"
"mira/backend/internal/calendar"
"mira/backend/internal/clients"
"mira/backend/internal/config"
"mira/backend/internal/database"
"mira/backend/internal/httpserver"
"mira/backend/internal/users"
)
func main() {
cfg := config.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: cfg.LogLevel,
}))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
db, err := database.Connect(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("database connection failed", "error", err)
os.Exit(1)
}
defer db.Close()
if err := db.Migrate(ctx, logger, "migrations"); err != nil {
logger.Error("database migration failed", "error", err)
os.Exit(1)
}
userStore := users.NewStore(db.Pool)
clientStore := clients.NewStore(db.Pool)
calendarStore := calendar.NewStore(db.Pool)
if err := auth.BootstrapSuperAdmin(ctx, cfg, userStore, logger); err != nil {
logger.Error("super admin bootstrap failed", "error", err)
os.Exit(1)
}
server := httpserver.New(cfg, logger, db, userStore, clientStore, calendarStore)
logger.Info("starting api", "addr", cfg.HTTPAddr, "env", cfg.AppEnv)
if err := server.Run(cfg.HTTPAddr); err != nil {
logger.Error("api stopped", "error", err)
os.Exit(1)
}
}

44
backend/go.mod Normal file
View File

@@ -0,0 +1,44 @@
module mira/backend
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.9.2
golang.org/x/crypto v0.23.0
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.29.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

110
backend/go.sum Normal file
View File

@@ -0,0 +1,110 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,42 @@
package auth
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"mira/backend/internal/config"
"mira/backend/internal/users"
)
func BootstrapSuperAdmin(ctx context.Context, cfg config.Config, store users.Store, logger *slog.Logger) error {
email := strings.TrimSpace(cfg.SuperAdminEmail)
password := cfg.SuperAdminPassword
if email == "" || password == "" {
if cfg.AppEnv == "production" {
return errors.New("SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD are required in production")
}
logger.Warn("super admin bootstrap skipped because credentials are not configured")
return nil
}
if len(password) < 12 {
return fmt.Errorf("SUPER_ADMIN_PASSWORD must have at least 12 characters")
}
hash, err := HashPassword(password)
if err != nil {
return fmt.Errorf("hash super admin password: %w", err)
}
user, err := store.UpsertSuperAdmin(ctx, email, hash)
if err != nil {
return fmt.Errorf("upsert super admin: %w", err)
}
logger.Info("super admin bootstrapped", "user_id", user.ID, "email", user.Email)
return nil
}

View File

@@ -0,0 +1,43 @@
package auth
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const ContextClaimsKey = "auth_claims"
const ContextUserIDKey = "auth_user_id"
const ContextRoleKey = "auth_role"
func RequireAuth(tokens TokenService) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" || !strings.HasPrefix(header, "Bearer ") {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
claims, err := tokens.ParseAccessToken(strings.TrimPrefix(header, "Bearer "))
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Sessao invalida ou expirada."})
return
}
c.Set(ContextClaimsKey, claims)
c.Set(ContextUserIDKey, claims.UserID)
c.Set(ContextRoleKey, claims.Role)
c.Next()
}
}
func ClaimsFromContext(c *gin.Context) (Claims, bool) {
value, ok := c.Get(ContextClaimsKey)
if !ok {
return Claims{}, false
}
claims, ok := value.(Claims)
return claims, ok
}

View File

@@ -0,0 +1,12 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(hash), err
}
func CheckPassword(password string, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@@ -0,0 +1,134 @@
package auth
import (
"errors"
"net/http"
"strings"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
users users.Store
tokens TokenService
}
func NewRoutes(users users.Store, tokens TokenService) Routes {
return Routes{
users: users,
tokens: tokens,
}
}
func (r Routes) Register(router *gin.RouterGroup) {
router.POST("/login", r.login)
router.POST("/invitations/accept", r.acceptInvitation)
router.POST("/logout", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."})
})
router.POST("/refresh", func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"message": "Renovacao de sessao ainda nao implementada."})
})
}
type loginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
func (r Routes) login(c *gin.Context) {
var input loginRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe e-mail e senha validos."})
return
}
user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email))
if errors.Is(err, users.ErrNotFound) {
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel fazer login."})
return
}
if !CheckPassword(input.Password, user.PasswordHash) || user.Status != "active" {
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
return
}
token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": token,
"token_type": "Bearer",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"status": user.Status,
},
})
}
type acceptInvitationRequest struct {
Token string `json:"token" binding:"required"`
Name string `json:"name" binding:"required,min=2"`
Password string `json:"password" binding:"required,min=12"`
}
func (r Routes) acceptInvitation(c *gin.Context) {
var input acceptInvitationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe nome, senha e token validos."})
return
}
passwordHash, err := HashPassword(input.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel proteger a senha."})
return
}
user, err := r.users.AcceptInvitation(
c.Request.Context(),
users.HashInvitationToken(strings.TrimSpace(input.Token)),
strings.TrimSpace(input.Name),
passwordHash,
)
if errors.Is(err, users.ErrInvitationNotFound) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Convite invalido ou expirado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel aceitar o convite."})
return
}
token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."})
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": token,
"token_type": "Bearer",
"user": gin.H{
"id": user.ID,
"email": user.Email,
"name": user.Name,
"role": user.Role,
"status": user.Status,
},
})
}

View File

@@ -0,0 +1,70 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
type TokenService struct {
secret []byte
accessTTL time.Duration
}
func NewTokenService(secret string, accessTTL time.Duration) TokenService {
return TokenService{
secret: []byte(secret),
accessTTL: accessTTL,
}
}
func (s TokenService) IssueAccessToken(userID string, email string, role string) (string, error) {
if len(s.secret) == 0 {
return "", errors.New("jwt secret is not configured")
}
now := time.Now()
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
}
func (s TokenService) ParseAccessToken(tokenValue string) (Claims, error) {
if len(s.secret) == 0 {
return Claims{}, errors.New("jwt secret is not configured")
}
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return s.secret, nil
})
if err != nil {
return Claims{}, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return Claims{}, errors.New("invalid token")
}
return *claims, nil
}

View File

@@ -0,0 +1,165 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrAttachmentNotFound = errors.New("attachment not found")
type Attachment struct {
ID string `json:"id"`
CalendarItemID string `json:"calendar_item_id"`
OriginalFilename string `json:"original_filename"`
StoredFilename string `json:"stored_filename"`
MimeType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
StorageDriver string `json:"storage_driver"`
StoragePath string `json:"-"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
}
type CreateAttachmentInput struct {
CalendarItemID string
OriginalFilename string
StoredFilename string
MimeType string
SizeBytes int64
StorageDriver string
StoragePath string
CreatedBy string
}
func (s Store) CreateAttachment(ctx context.Context, input CreateAttachmentInput) (Attachment, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO calendar_item_attachments (
calendar_item_id,
original_filename,
stored_filename,
mime_type,
size_bytes,
storage_driver,
storage_path,
created_by
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid)
RETURNING
id::text,
calendar_item_id::text,
original_filename,
stored_filename,
mime_type,
size_bytes,
storage_driver,
storage_path,
created_by::text,
created_at
`, input.CalendarItemID, input.OriginalFilename, input.StoredFilename, input.MimeType, input.SizeBytes, input.StorageDriver, input.StoragePath, input.CreatedBy)
return scanAttachment(row)
}
func (s Store) ListAttachments(ctx context.Context, calendarItemID string, viewerUserID string) ([]Attachment, error) {
rows, err := s.db.Query(ctx, `
SELECT
calendar_item_attachments.id::text,
calendar_item_attachments.calendar_item_id::text,
calendar_item_attachments.original_filename,
calendar_item_attachments.stored_filename,
calendar_item_attachments.mime_type,
calendar_item_attachments.size_bytes,
calendar_item_attachments.storage_driver,
calendar_item_attachments.storage_path,
calendar_item_attachments.created_by::text,
calendar_item_attachments.created_at
FROM calendar_item_attachments
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
WHERE calendar_item_attachments.calendar_item_id = $1::uuid
AND (
$2 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $2::uuid
)
)
ORDER BY calendar_item_attachments.created_at DESC
`, calendarItemID, viewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
attachments := []Attachment{}
for rows.Next() {
attachment, err := scanAttachment(rows)
if err != nil {
return nil, err
}
attachments = append(attachments, attachment)
}
return attachments, rows.Err()
}
func (s Store) FindAttachment(ctx context.Context, id string, viewerUserID string) (Attachment, error) {
row := s.db.QueryRow(ctx, `
SELECT
calendar_item_attachments.id::text,
calendar_item_attachments.calendar_item_id::text,
calendar_item_attachments.original_filename,
calendar_item_attachments.stored_filename,
calendar_item_attachments.mime_type,
calendar_item_attachments.size_bytes,
calendar_item_attachments.storage_driver,
calendar_item_attachments.storage_path,
calendar_item_attachments.created_by::text,
calendar_item_attachments.created_at
FROM calendar_item_attachments
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
WHERE calendar_item_attachments.id = $1::uuid
AND (
$2 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $2::uuid
)
)
`, id, viewerUserID)
return scanAttachment(row)
}
type attachmentScanner interface {
Scan(dest ...any) error
}
func scanAttachment(row attachmentScanner) (Attachment, error) {
var attachment Attachment
err := row.Scan(
&attachment.ID,
&attachment.CalendarItemID,
&attachment.OriginalFilename,
&attachment.StoredFilename,
&attachment.MimeType,
&attachment.SizeBytes,
&attachment.StorageDriver,
&attachment.StoragePath,
&attachment.CreatedBy,
&attachment.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Attachment{}, ErrAttachmentNotFound
}
if err != nil {
return Attachment{}, err
}
return attachment, nil
}

View File

@@ -0,0 +1,23 @@
package calendar
import (
"context"
"encoding/json"
)
func (s Store) RecordAudit(ctx context.Context, actorUserID string, action string, entityType string, entityID string, metadata map[string]any) error {
payload := "{}"
if metadata != nil {
bytes, err := json.Marshal(metadata)
if err != nil {
return err
}
payload = string(bytes)
}
_, err := s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, metadata)
VALUES (NULLIF($1, '')::uuid, $2, $3, NULLIF($4, '')::uuid, $5::jsonb)
`, actorUserID, action, entityType, entityID, payload)
return err
}

View File

@@ -0,0 +1,248 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrCustomDateNotFound = errors.New("custom date not found")
type CustomDate struct {
ID string `json:"id"`
ClientID *string `json:"client_id"`
Name string `json:"name"`
Description string `json:"description"`
Date string `json:"date"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
Category string `json:"category"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (s Store) ListCustomDatesByYear(ctx context.Context, year int, clientID string, viewerUserID string) ([]CustomDate, error) {
rows, err := s.db.Query(ctx, `
SELECT
id::text,
client_id::text,
name,
description,
CASE
WHEN recurs_annually THEN make_date(
$1,
EXTRACT(MONTH FROM date)::int,
LEAST(
EXTRACT(DAY FROM date)::int,
EXTRACT(DAY FROM (date_trunc('month', make_date($1, EXTRACT(MONTH FROM date)::int, 1)) + interval '1 month - 1 day'))::int
)
)::text
ELSE date::text
END,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
FROM commemorative_dates
WHERE (
(date >= make_date($1, 1, 1) AND date < make_date($1 + 1, 1, 1))
OR (recurs_annually AND date < make_date($1 + 1, 1, 1))
)
AND ($2 = '' OR client_id IS NULL OR client_id::text = $2)
AND (
$3 = ''
OR (
visibility = 'client'
AND (
client_id IS NULL
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = commemorative_dates.client_id
AND client_users.user_id = $3::uuid
)
)
)
)
ORDER BY date ASC, name ASC
`, year, clientID, viewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
dates := []CustomDate{}
for rows.Next() {
date, err := scanCustomDate(rows)
if err != nil {
return nil, err
}
dates = append(dates, date)
}
return dates, rows.Err()
}
func (s Store) CreateCustomDate(ctx context.Context, input CreateCustomDateInput) (CustomDate, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO commemorative_dates (
client_id,
name,
description,
date,
recurs_annually,
visibility,
category,
created_by
)
VALUES (NULLIF($1, '')::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid)
RETURNING
id::text,
client_id::text,
name,
description,
date::text,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
`,
input.ClientID,
input.Name,
input.Description,
input.Date,
input.RecursAnnually,
input.Visibility,
input.Category,
input.CreatedBy,
)
return scanCustomDate(row)
}
type UpdateCustomDateInput struct {
ID string
ClientID string
Name string
Description string
Date string
RecursAnnually bool
Visibility string
}
func (s Store) UpdateCustomDate(ctx context.Context, input UpdateCustomDateInput) (CustomDate, error) {
row := s.db.QueryRow(ctx, `
UPDATE commemorative_dates
SET
client_id = NULLIF($2, '')::uuid,
name = $3,
description = $4,
date = $5::date,
recurs_annually = $6,
visibility = $7,
updated_at = now()
WHERE id = $1::uuid
AND category != 'feriados-brasil'
RETURNING
id::text,
client_id::text,
name,
description,
date::text,
recurs_annually,
visibility,
category,
created_by::text,
created_at,
updated_at
`, input.ID, input.ClientID, input.Name, input.Description, input.Date, input.RecursAnnually, input.Visibility)
return scanCustomDate(row)
}
func (s Store) DeleteCustomDate(ctx context.Context, id string) error {
command, err := s.db.Exec(ctx, `
DELETE FROM commemorative_dates
WHERE id = $1::uuid
AND category != 'feriados-brasil'
`, id)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return ErrCustomDateNotFound
}
return nil
}
func (s Store) UpsertExternalCommemorativeDates(ctx context.Context, dates []ExternalCommemorativeDate) error {
for _, date := range dates {
if date.Name == "" || date.Date == "" {
continue
}
if _, err := s.db.Exec(ctx, `
INSERT INTO commemorative_dates (
client_id,
name,
description,
date,
recurs_annually,
visibility,
category
)
VALUES (NULL, $1, $2, $3::date, false, 'client', 'feriados-brasil')
ON CONFLICT DO NOTHING
`, date.Name, date.Description, date.Date); err != nil {
return err
}
}
return nil
}
type CreateCustomDateInput struct {
ClientID string
Name string
Description string
Date string
RecursAnnually bool
Visibility string
Category string
CreatedBy string
}
type customDateScanner interface {
Scan(dest ...any) error
}
func scanCustomDate(row customDateScanner) (CustomDate, error) {
var date CustomDate
err := row.Scan(
&date.ID,
&date.ClientID,
&date.Name,
&date.Description,
&date.Date,
&date.RecursAnnually,
&date.Visibility,
&date.Category,
&date.CreatedBy,
&date.CreatedAt,
&date.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return CustomDate{}, ErrCustomDateNotFound
}
if err != nil {
return CustomDate{}, err
}
return date, nil
}

View File

@@ -0,0 +1,329 @@
package calendar
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrCalendarItemNotFound = errors.New("calendar item not found")
type CalendarItem struct {
ID string `json:"id"`
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
Title string `json:"title"`
Description string `json:"description"`
ContentType string `json:"content_type"`
Status string `json:"status"`
OwnerID *string `json:"owner_id"`
ScheduledDate string `json:"scheduled_date"`
ScheduledAt *time.Time `json:"scheduled_at"`
CopyText string `json:"copy_text"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListCalendarItemsFilter struct {
ClientID string
ViewerUserID string
From string
To string
}
func (s Store) ListCalendarItems(ctx context.Context, filter ListCalendarItemsFilter) ([]CalendarItem, error) {
rows, err := s.db.Query(ctx, `
SELECT
calendar_items.id::text,
calendar_items.client_id::text,
clients.name,
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
FROM calendar_items
INNER JOIN clients ON clients.id = calendar_items.client_id
WHERE calendar_items.scheduled_date >= $1::date
AND calendar_items.scheduled_date <= $2::date
AND ($3 = '' OR calendar_items.client_id::text = $3)
AND (
$4 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $4::uuid
)
)
ORDER BY calendar_items.scheduled_date ASC, calendar_items.created_at ASC
`, filter.From, filter.To, filter.ClientID, filter.ViewerUserID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CalendarItem{}
for rows.Next() {
item, err := scanCalendarItem(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s Store) FindCalendarItem(ctx context.Context, id string, viewerUserID string) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
SELECT
calendar_items.id::text,
calendar_items.client_id::text,
clients.name,
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
FROM calendar_items
INNER JOIN clients ON clients.id = calendar_items.client_id
WHERE calendar_items.id = $1::uuid
AND (
$2 = ''
OR EXISTS (
SELECT 1
FROM client_users
WHERE client_users.client_id = calendar_items.client_id
AND client_users.user_id = $2::uuid
)
)
`, id, viewerUserID)
return scanCalendarItem(row)
}
type CreateCalendarItemInput struct {
ClientID string
Title string
Description string
ContentType string
Status string
ScheduledDate string
CopyText string
InternalNotes string
ClientNotes string
CreatedBy string
}
func (s Store) CreateCalendarItem(ctx context.Context, input CreateCalendarItemInput) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO calendar_items (
client_id,
title,
description,
content_type,
status,
scheduled_date,
copy_text,
internal_notes,
client_notes,
created_by
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::date, $7, $8, $9, NULLIF($10, '')::uuid)
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`,
input.ClientID,
input.Title,
input.Description,
input.ContentType,
input.Status,
input.ScheduledDate,
input.CopyText,
input.InternalNotes,
input.ClientNotes,
input.CreatedBy,
)
return scanCalendarItem(row)
}
type UpdateCalendarItemInput struct {
ID string
Title string
UpdateTitle bool
Description string
UpdateDescription bool
Status string
UpdateStatus bool
ScheduledDate string
UpdateScheduledDate bool
CopyText string
UpdateCopyText bool
InternalNotes string
UpdateInternalNotes bool
ClientNotes string
UpdateClientNotes bool
}
func (s Store) UpdateCalendarItem(ctx context.Context, input UpdateCalendarItemInput) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
UPDATE calendar_items
SET
title = CASE WHEN $2 THEN $3 ELSE title END,
description = CASE WHEN $4 THEN $5 ELSE description END,
status = CASE WHEN $6 THEN $7 ELSE status END,
scheduled_date = CASE WHEN $8 THEN NULLIF($9, '')::date ELSE scheduled_date END,
copy_text = CASE WHEN $10 THEN $11 ELSE copy_text END,
internal_notes = CASE WHEN $12 THEN $13 ELSE internal_notes END,
client_notes = CASE WHEN $14 THEN $15 ELSE client_notes END,
updated_at = now()
WHERE id = $1::uuid
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`,
input.ID,
input.UpdateTitle,
input.Title,
input.UpdateDescription,
input.Description,
input.UpdateStatus,
input.Status,
input.UpdateScheduledDate,
input.ScheduledDate,
input.UpdateCopyText,
input.CopyText,
input.UpdateInternalNotes,
input.InternalNotes,
input.UpdateClientNotes,
input.ClientNotes,
)
return scanCalendarItem(row)
}
func (s Store) CancelCalendarItem(ctx context.Context, id string) (CalendarItem, error) {
row := s.db.QueryRow(ctx, `
UPDATE calendar_items
SET status = 'cancelado', updated_at = now()
WHERE id = $1::uuid
RETURNING
calendar_items.id::text,
calendar_items.client_id::text,
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
calendar_items.title,
calendar_items.description,
calendar_items.content_type,
calendar_items.status,
calendar_items.owner_id::text,
calendar_items.scheduled_date::text,
calendar_items.scheduled_at,
calendar_items.copy_text,
calendar_items.internal_notes,
calendar_items.client_notes,
calendar_items.created_by::text,
calendar_items.created_at,
calendar_items.updated_at
`, id)
return scanCalendarItem(row)
}
func (s Store) DeleteCalendarItem(ctx context.Context, id string) error {
command, err := s.db.Exec(ctx, `
DELETE FROM calendar_items
WHERE id = $1::uuid
`, id)
if err != nil {
return err
}
if command.RowsAffected() == 0 {
return ErrCalendarItemNotFound
}
return nil
}
type calendarItemScanner interface {
Scan(dest ...any) error
}
func scanCalendarItem(row calendarItemScanner) (CalendarItem, error) {
var item CalendarItem
err := row.Scan(
&item.ID,
&item.ClientID,
&item.ClientName,
&item.Title,
&item.Description,
&item.ContentType,
&item.Status,
&item.OwnerID,
&item.ScheduledDate,
&item.ScheduledAt,
&item.CopyText,
&item.InternalNotes,
&item.ClientNotes,
&item.CreatedBy,
&item.CreatedAt,
&item.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return CalendarItem{}, ErrCalendarItemNotFound
}
if err != nil {
return CalendarItem{}, err
}
return item, nil
}

View File

@@ -0,0 +1,136 @@
package calendar
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
type Holiday struct {
Date string `json:"date"`
Name string `json:"name"`
Type string `json:"type"`
}
type Provider interface {
NationalHolidays(ctx context.Context, year int) ([]Holiday, error)
}
type CommemorativeProvider interface {
CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error)
}
type ExternalCommemorativeDate struct {
Date string
Name string
Description string
SourceID string
}
type BrasilAPIProvider struct {
baseURL string
client *http.Client
}
type FeriadosBrasilProvider struct {
baseURL string
client *http.Client
}
func NewFeriadosBrasilProvider() FeriadosBrasilProvider {
return FeriadosBrasilProvider{
baseURL: "https://raw.githubusercontent.com/joaopbini/feriados-brasil/master/dados/comemorativas/json",
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type feriadosBrasilCommemorativeDate struct {
ID string `json:"id"`
Data string `json:"data"`
Nome string `json:"nome"`
Tipo string `json:"tipo"`
Descricao string `json:"descricao"`
UF string `json:"uf"`
CodigoIBGE *int `json:"codigo_ibge"`
}
func (p FeriadosBrasilProvider) CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error) {
url := fmt.Sprintf("%s/%d.json", p.baseURL, year)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("feriados-brasil returned status %d", resp.StatusCode)
}
var records []feriadosBrasilCommemorativeDate
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
return nil, err
}
dates := make([]ExternalCommemorativeDate, 0, len(records))
for _, record := range records {
if strings.ToUpper(strings.TrimSpace(record.Tipo)) != "COMEMORATIVA" {
continue
}
parsed, err := time.Parse("02/01/2006", strings.TrimSpace(record.Data))
if err != nil {
continue
}
dates = append(dates, ExternalCommemorativeDate{
Date: parsed.Format("2006-01-02"),
Name: strings.TrimSpace(record.Nome),
Description: strings.TrimSpace(record.Descricao),
SourceID: strings.TrimSpace(record.ID),
})
}
return dates, nil
}
func NewBrasilAPIProvider(baseURL string) BrasilAPIProvider {
return BrasilAPIProvider{
baseURL: baseURL,
client: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (p BrasilAPIProvider) NationalHolidays(ctx context.Context, year int) ([]Holiday, error) {
url := fmt.Sprintf("%s/api/feriados/v1/%d", p.baseURL, year)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := p.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brasilapi returned status %d", resp.StatusCode)
}
var holidays []Holiday
if err := json.NewDecoder(resp.Body).Decode(&holidays); err != nil {
return nil, err
}
return holidays, nil
}

View File

@@ -0,0 +1,740 @@
package calendar
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"mira/backend/internal/auth"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
provider Provider
commemorative CommemorativeProvider
uploadMaxSizeBytes int64
uploadAllowedMIMEs map[string]bool
storageDriver string
storageLocalPath string
}
type RouteOptions struct {
UploadMaxSizeMB int
UploadAllowedMIMEs []string
StorageDriver string
StorageLocalPath string
}
func NewRoutes(store Store, provider Provider, commemorative CommemorativeProvider, options RouteOptions) Routes {
allowedMIMEs := make(map[string]bool, len(options.UploadAllowedMIMEs))
for _, mime := range options.UploadAllowedMIMEs {
allowedMIMEs[strings.TrimSpace(mime)] = true
}
if options.UploadMaxSizeMB <= 0 {
options.UploadMaxSizeMB = 10
}
if options.StorageDriver == "" {
options.StorageDriver = "local"
}
if options.StorageLocalPath == "" {
options.StorageLocalPath = "/var/lib/mira/uploads"
}
return Routes{
store: store,
provider: provider,
commemorative: commemorative,
uploadMaxSizeBytes: int64(options.UploadMaxSizeMB) * 1024 * 1024,
uploadAllowedMIMEs: allowedMIMEs,
storageDriver: options.StorageDriver,
storageLocalPath: options.StorageLocalPath,
}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("/week", func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{"message": "Dashboard semanal ainda nao implementado."})
})
router.GET("/holidays", r.holidays)
router.GET("/custom-dates", r.customDates)
router.POST("/custom-dates", r.createCustomDate)
router.PATCH("/custom-dates/:customDateId", r.updateCustomDate)
router.DELETE("/custom-dates/:customDateId", r.deleteCustomDate)
router.GET("/items", r.calendarItems)
router.POST("/items", r.createCalendarItem)
router.GET("/items/:itemId", r.getCalendarItem)
router.PATCH("/items/:itemId", r.updateCalendarItem)
router.DELETE("/items/:itemId", r.cancelCalendarItem)
router.GET("/items/:itemId/attachments", r.listAttachments)
router.POST("/items/:itemId/attachments", r.uploadAttachment)
router.GET("/attachments/:attachmentId/download", r.downloadAttachment)
}
func (r Routes) holidays(c *gin.Context) {
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
holidays, err := r.store.ListHolidaysByYear(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
return
}
if len(holidays) == 0 {
imported, err := r.provider.NationalHolidays(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"message": "Nao foi possivel consultar os feriados nacionais."})
return
}
if err := r.store.UpsertHolidays(c.Request.Context(), "brasilapi", imported); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar feriados."})
return
}
holidays, err = r.store.ListHolidaysByYear(c.Request.Context(), year)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
return
}
}
c.JSON(http.StatusOK, gin.H{"holidays": holidays})
}
func (r Routes) customDates(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
if r.commemorative != nil {
initialDates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, "", viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
return
}
hasImported := false
for _, date := range initialDates {
if date.Category == "feriados-brasil" {
hasImported = true
break
}
}
if !hasImported {
imported, err := r.commemorative.CommemorativeDates(c.Request.Context(), year)
if err == nil && len(imported) > 0 {
_ = r.store.UpsertExternalCommemorativeDates(c.Request.Context(), imported)
}
}
}
dates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, c.Query("client_id"), viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
return
}
c.JSON(http.StatusOK, gin.H{"custom_dates": dates})
}
type createCustomDateRequest struct {
ClientID string `json:"client_id"`
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
Date string `json:"date" binding:"required"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
Category string `json:"category"`
}
func (r Routes) createCustomDate(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createCustomDateRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
return
}
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
visibility := strings.TrimSpace(input.Visibility)
if visibility == "" {
visibility = "agency"
}
if visibility != "agency" && visibility != "client" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
return
}
category := strings.TrimSpace(input.Category)
if category == "" {
category = "custom"
}
date, err := r.store.CreateCustomDate(c.Request.Context(), CreateCustomDateInput{
ClientID: strings.TrimSpace(input.ClientID),
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Date: input.Date,
RecursAnnually: input.RecursAnnually,
Visibility: visibility,
Category: category,
CreatedBy: claims.UserID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.created", "commemorative_date", date.ID, map[string]any{
"name": date.Name,
"client_id": date.ClientID,
})
c.JSON(http.StatusCreated, gin.H{"custom_date": date})
}
type updateCustomDateRequest struct {
ClientID string `json:"client_id"`
Name string `json:"name" binding:"required,min=2"`
Description string `json:"description"`
Date string `json:"date" binding:"required"`
RecursAnnually bool `json:"recurs_annually"`
Visibility string `json:"visibility"`
}
func (r Routes) updateCustomDate(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input updateCustomDateRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
return
}
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
visibility := strings.TrimSpace(input.Visibility)
if visibility == "" {
visibility = "agency"
}
if visibility != "agency" && visibility != "client" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
return
}
date, err := r.store.UpdateCustomDate(c.Request.Context(), UpdateCustomDateInput{
ID: c.Param("customDateId"),
ClientID: strings.TrimSpace(input.ClientID),
Name: strings.TrimSpace(input.Name),
Description: strings.TrimSpace(input.Description),
Date: input.Date,
RecursAnnually: input.RecursAnnually,
Visibility: visibility,
})
if errors.Is(err, ErrCustomDateNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.updated", "commemorative_date", date.ID, map[string]any{
"name": date.Name,
"client_id": date.ClientID,
})
c.JSON(http.StatusOK, gin.H{"custom_date": date})
}
func (r Routes) deleteCustomDate(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
err := r.store.DeleteCustomDate(c.Request.Context(), c.Param("customDateId"))
if errors.Is(err, ErrCustomDateNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a data personalizada."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.deleted", "commemorative_date", c.Param("customDateId"), nil)
c.JSON(http.StatusOK, gin.H{"message": "Data personalizada excluida."})
}
func (r Routes) calendarItems(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
from := strings.TrimSpace(c.Query("from"))
to := strings.TrimSpace(c.Query("to"))
if from == "" || to == "" {
year := time.Now().Year()
if value := c.Query("year"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1900 || parsed > 2199 {
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
return
}
year = parsed
}
from = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
to = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
}
if _, err := time.Parse("2006-01-02", from); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data inicial invalida. Use o formato YYYY-MM-DD."})
return
}
if _, err := time.Parse("2006-01-02", to); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data final invalida. Use o formato YYYY-MM-DD."})
return
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
items, err := r.store.ListCalendarItems(c.Request.Context(), ListCalendarItemsFilter{
ClientID: strings.TrimSpace(c.Query("client_id")),
ViewerUserID: viewerUserID,
From: from,
To: to,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar postagens."})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
type createCalendarItemRequest struct {
ClientID string `json:"client_id" binding:"required"`
Title string `json:"title" binding:"required,min=2"`
Description string `json:"description"`
ContentType string `json:"content_type"`
Status string `json:"status"`
ScheduledDate string `json:"scheduled_date" binding:"required"`
CopyText string `json:"copy_text"`
InternalNotes string `json:"internal_notes"`
ClientNotes string `json:"client_notes"`
}
func (r Routes) createCalendarItem(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createCalendarItemRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da postagem."})
return
}
if _, err := time.Parse("2006-01-02", input.ScheduledDate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
contentType := strings.TrimSpace(input.ContentType)
if contentType == "" {
contentType = "post"
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "rascunho"
}
if !isValidCalendarItemStatus(status) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
return
}
item, err := r.store.CreateCalendarItem(c.Request.Context(), CreateCalendarItemInput{
ClientID: strings.TrimSpace(input.ClientID),
Title: strings.TrimSpace(input.Title),
Description: strings.TrimSpace(input.Description),
ContentType: contentType,
Status: status,
ScheduledDate: input.ScheduledDate,
CopyText: strings.TrimSpace(input.CopyText),
InternalNotes: strings.TrimSpace(input.InternalNotes),
ClientNotes: strings.TrimSpace(input.ClientNotes),
CreatedBy: claims.UserID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.created", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"client_id": item.ClientID,
"status": item.Status,
})
c.JSON(http.StatusCreated, gin.H{"item": item})
}
func (r Routes) getCalendarItem(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID)
if errors.Is(err, ErrCalendarItemNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."})
return
}
c.JSON(http.StatusOK, gin.H{"item": item})
}
type updateCalendarItemRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
ScheduledDate *string `json:"scheduled_date"`
CopyText *string `json:"copy_text"`
InternalNotes *string `json:"internal_notes"`
ClientNotes *string `json:"client_notes"`
}
func (r Routes) updateCalendarItem(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input updateCalendarItemRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
return
}
update := UpdateCalendarItemInput{ID: c.Param("itemId")}
if input.Title != nil {
update.UpdateTitle = true
update.Title = strings.TrimSpace(*input.Title)
if update.Title == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Titulo invalido."})
return
}
}
if input.Description != nil {
update.UpdateDescription = true
update.Description = strings.TrimSpace(*input.Description)
}
if input.Status != nil {
update.UpdateStatus = true
update.Status = strings.TrimSpace(*input.Status)
if !isValidCalendarItemStatus(update.Status) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
return
}
}
if input.ScheduledDate != nil {
update.UpdateScheduledDate = true
update.ScheduledDate = strings.TrimSpace(*input.ScheduledDate)
if _, err := time.Parse("2006-01-02", update.ScheduledDate); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
return
}
}
if input.CopyText != nil {
update.UpdateCopyText = true
update.CopyText = strings.TrimSpace(*input.CopyText)
}
if input.InternalNotes != nil {
update.UpdateInternalNotes = true
update.InternalNotes = strings.TrimSpace(*input.InternalNotes)
}
if input.ClientNotes != nil {
update.UpdateClientNotes = true
update.ClientNotes = strings.TrimSpace(*input.ClientNotes)
}
item, err := r.store.UpdateCalendarItem(c.Request.Context(), update)
if errors.Is(err, ErrCalendarItemNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.updated", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"status": item.Status,
})
c.JSON(http.StatusOK, gin.H{"item": item})
}
func (r Routes) cancelCalendarItem(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), "")
if errors.Is(err, ErrCalendarItemNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."})
return
}
if err := r.store.DeleteCalendarItem(c.Request.Context(), item.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a postagem."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.deleted", "calendar_item", item.ID, map[string]any{
"title": item.Title,
"status": item.Status,
})
c.JSON(http.StatusOK, gin.H{"message": "Postagem excluida."})
}
func (r Routes) listAttachments(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
attachments, err := r.store.ListAttachments(c.Request.Context(), c.Param("itemId"), viewerUserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar anexos."})
return
}
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
}
func (r Routes) uploadAttachment(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes)
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."})
return
}
defer file.Close()
if header.Size > r.uploadMaxSizeBytes {
c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."})
return
}
head := make([]byte, 512)
n, err := file.Read(head)
if err != nil && err != io.EOF {
c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."})
return
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."})
return
}
mimeType := http.DetectContentType(head[:n])
if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] {
c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."})
return
}
storedFilename, err := randomStoredFilename(header.Filename)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."})
return
}
itemDir := filepath.Join(r.storageLocalPath, c.Param("itemId"))
if err := os.MkdirAll(itemDir, 0o750); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."})
return
}
storagePath := filepath.Join(itemDir, storedFilename)
destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
return
}
defer destination.Close()
sizeBytes, err := io.Copy(destination, file)
if err != nil {
_ = os.Remove(storagePath)
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
return
}
attachment, err := r.store.CreateAttachment(c.Request.Context(), CreateAttachmentInput{
CalendarItemID: c.Param("itemId"),
OriginalFilename: filepath.Base(header.Filename),
StoredFilename: storedFilename,
MimeType: mimeType,
SizeBytes: sizeBytes,
StorageDriver: r.storageDriver,
StoragePath: storagePath,
CreatedBy: claims.UserID,
})
if err != nil {
_ = os.Remove(storagePath)
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."})
return
}
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "attachment.created", "calendar_item_attachment", attachment.ID, map[string]any{
"calendar_item_id": attachment.CalendarItemID,
"filename": attachment.OriginalFilename,
"mime_type": attachment.MimeType,
"size_bytes": attachment.SizeBytes,
})
c.JSON(http.StatusCreated, gin.H{"attachment": attachment})
}
func (r Routes) downloadAttachment(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
viewerUserID := ""
if claims.Role == users.RoleClientViewer {
viewerUserID = claims.UserID
}
attachment, err := r.store.FindAttachment(c.Request.Context(), c.Param("attachmentId"), viewerUserID)
if errors.Is(err, ErrAttachmentNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o anexo."})
return
}
c.Header("Content-Type", attachment.MimeType)
c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(attachment.OriginalFilename, `"`, "")+`"`)
c.File(attachment.StoragePath)
}
func randomStoredFilename(original string) (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
extension := strings.ToLower(filepath.Ext(original))
return hex.EncodeToString(bytes) + extension, nil
}
func isValidCalendarItemStatus(status string) bool {
switch status {
case "rascunho", "planejado", "em_producao", "em_revisao", "aprovado", "publicado", "cancelado":
return true
default:
return false
}
}

View File

@@ -0,0 +1,83 @@
package calendar
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type HolidayRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Name string `json:"name"`
Type string `json:"type"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) ListHolidaysByYear(ctx context.Context, year int) ([]HolidayRecord, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, date::text, name, type, source, created_at, updated_at
FROM holidays
WHERE date >= make_date($1, 1, 1)
AND date < make_date($1 + 1, 1, 1)
ORDER BY date ASC, name ASC
`, year)
if err != nil {
return nil, err
}
defer rows.Close()
holidays := []HolidayRecord{}
for rows.Next() {
var holiday HolidayRecord
if err := rows.Scan(
&holiday.ID,
&holiday.Date,
&holiday.Name,
&holiday.Type,
&holiday.Source,
&holiday.CreatedAt,
&holiday.UpdatedAt,
); err != nil {
return nil, err
}
holidays = append(holidays, holiday)
}
return holidays, rows.Err()
}
func (s Store) UpsertHolidays(ctx context.Context, source string, holidays []Holiday) error {
for _, holiday := range holidays {
payload, err := json.Marshal(holiday)
if err != nil {
return err
}
if _, err := s.db.Exec(ctx, `
INSERT INTO holidays (date, name, type, source, raw_payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
ON CONFLICT (date, source, name)
DO UPDATE SET
type = EXCLUDED.type,
raw_payload = EXCLUDED.raw_payload,
updated_at = now()
`, holiday.Date, holiday.Name, holiday.Type, source, string(payload)); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,18 @@
package clients
import "time"
type Client struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Status string `json:"status"`
WebsiteURL string `json:"website_url"`
InstagramURL string `json:"instagram_url"`
FacebookURL string `json:"facebook_url"`
LinkedinURL string `json:"linkedin_url"`
Color string `json:"color"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,196 @@
package clients
import (
"errors"
"net/http"
"regexp"
"strings"
"mira/backend/internal/auth"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
}
func NewRoutes(store Store) Routes {
return Routes{store: store}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("", r.list)
router.POST("", r.create)
router.GET("/:clientId", r.get)
router.PATCH("/:clientId", r.update)
router.DELETE("/:clientId", r.delete)
}
func (r Routes) list(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
var clients []Client
var err error
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
clients, err = r.store.List(c.Request.Context())
} else {
clients, err = r.store.ListForUser(c.Request.Context(), claims.UserID)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar clientes."})
return
}
c.JSON(http.StatusOK, gin.H{"clients": clients})
}
type createClientRequest struct {
Name string `json:"name" binding:"required,min=2"`
}
func (r Routes) create(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createClientRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe um nome de cliente valido."})
return
}
client, err := r.store.Create(c.Request.Context(), input.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o cliente."})
return
}
c.JSON(http.StatusCreated, gin.H{"client": client})
}
func (r Routes) get(c *gin.Context) {
if !r.canReadClient(c, c.Param("clientId")) {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
client, err := r.store.FindByID(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
type updateClientRequest struct {
Name string `json:"name"`
Status string `json:"status"`
WebsiteURL string `json:"website_url"`
InstagramURL string `json:"instagram_url"`
FacebookURL string `json:"facebook_url"`
LinkedinURL string `json:"linkedin_url"`
Color string `json:"color"`
Notes string `json:"notes"`
}
func (r Routes) update(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input updateClientRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
return
}
color := strings.TrimSpace(input.Color)
if color != "" && !hexColorPattern.MatchString(color) {
c.JSON(http.StatusBadRequest, gin.H{"message": "Cor invalida."})
return
}
client, err := r.store.Update(c.Request.Context(), c.Param("clientId"), input.Name, input.Status, input.WebsiteURL, input.InstagramURL, input.FacebookURL, input.LinkedinURL, color, input.Notes)
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
var hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
func (r Routes) archive(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
client, err := r.store.Archive(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel arquivar o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"client": client})
}
func (r Routes) delete(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok || claims.Role != users.RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
err := r.store.Delete(c.Request.Context(), c.Param("clientId"))
if errors.Is(err, ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o cliente."})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Cliente excluido."})
}
func (r Routes) canReadClient(c *gin.Context, clientID string) bool {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
return false
}
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
return true
}
allowed, err := r.store.UserCanAccess(c.Request.Context(), claims.UserID, clientID)
return err == nil && allowed
}

View File

@@ -0,0 +1,201 @@
package clients
import (
"context"
"errors"
"regexp"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("client not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) List(ctx context.Context) ([]Client, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
FROM clients
ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
clients := []Client{}
for rows.Next() {
client, err := scanClient(rows)
if err != nil {
return nil, err
}
clients = append(clients, client)
}
return clients, rows.Err()
}
func (s Store) ListForUser(ctx context.Context, userID string) ([]Client, error) {
rows, err := s.db.Query(ctx, `
SELECT clients.id::text, clients.name, clients.slug, clients.status, clients.website_url, clients.instagram_url, clients.facebook_url, clients.linkedin_url, clients.color, clients.notes, clients.created_at, clients.updated_at
FROM clients
INNER JOIN client_users ON client_users.client_id = clients.id
WHERE client_users.user_id = $1::uuid
ORDER BY clients.name ASC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
clients := []Client{}
for rows.Next() {
client, err := scanClient(rows)
if err != nil {
return nil, err
}
clients = append(clients, client)
}
return clients, rows.Err()
}
func (s Store) UserCanAccess(ctx context.Context, userID string, clientID string) (bool, error) {
var exists bool
err := s.db.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1
FROM client_users
WHERE user_id = $1::uuid
AND client_id = $2::uuid
)
`, userID, clientID).Scan(&exists)
return exists, err
}
func (s Store) FindByID(ctx context.Context, id string) (Client, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
FROM clients
WHERE id = $1
`, id)
return scanClient(row)
}
func (s Store) Create(ctx context.Context, name string) (Client, error) {
slug := slugify(name)
row := s.db.QueryRow(ctx, `
INSERT INTO clients (name, slug, status)
VALUES ($1, $2, 'active')
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, strings.TrimSpace(name), slug)
return scanClient(row)
}
func (s Store) Update(ctx context.Context, id string, name string, status string, websiteURL string, instagramURL string, facebookURL string, linkedinURL string, color string, notes string) (Client, error) {
row := s.db.QueryRow(ctx, `
UPDATE clients
SET
name = COALESCE(NULLIF($2, ''), name),
slug = CASE WHEN NULLIF($2, '') IS NULL THEN slug ELSE $3 END,
status = COALESCE(NULLIF($4, ''), status),
website_url = $5,
instagram_url = $6,
facebook_url = $7,
linkedin_url = $8,
color = COALESCE(NULLIF($9, ''), color),
notes = $10,
updated_at = now()
WHERE id = $1
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, id, strings.TrimSpace(name), slugify(name), strings.TrimSpace(status), strings.TrimSpace(websiteURL), strings.TrimSpace(instagramURL), strings.TrimSpace(facebookURL), strings.TrimSpace(linkedinURL), strings.TrimSpace(color), strings.TrimSpace(notes))
return scanClient(row)
}
func (s Store) Archive(ctx context.Context, id string) (Client, error) {
row := s.db.QueryRow(ctx, `
UPDATE clients
SET status = 'archived', updated_at = now()
WHERE id = $1
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
`, id)
return scanClient(row)
}
func (s Store) Delete(ctx context.Context, id string) error {
result, err := s.db.Exec(ctx, `
DELETE FROM clients
WHERE id = $1
`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanClient(row scanner) (Client, error) {
var client Client
err := row.Scan(
&client.ID,
&client.Name,
&client.Slug,
&client.Status,
&client.WebsiteURL,
&client.InstagramURL,
&client.FacebookURL,
&client.LinkedinURL,
&client.Color,
&client.Notes,
&client.CreatedAt,
&client.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Client{}, ErrNotFound
}
if err != nil {
return Client{}, err
}
return client, nil
}
func slugify(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
replacements := map[string]string{
"á": "a", "à": "a", "â": "a", "ã": "a", "ä": "a",
"é": "e", "è": "e", "ê": "e", "ë": "e",
"í": "i", "ì": "i", "î": "i", "ï": "i",
"ó": "o", "ò": "o", "ô": "o", "õ": "o", "ö": "o",
"ú": "u", "ù": "u", "û": "u", "ü": "u",
"ç": "c",
}
for from, to := range replacements {
value = strings.ReplaceAll(value, from, to)
}
value = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(value, "-")
value = strings.Trim(value, "-")
if value == "" {
return "cliente"
}
return value
}

View File

@@ -0,0 +1,119 @@
package config
import (
"log/slog"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
AppEnv string
AppPublicURL string
AppTimezone string
HTTPAddr string
LogLevel slog.Level
DatabaseURL string
SuperAdminEmail string
SuperAdminPassword string
JWTSecret string
AccessTokenTTL time.Duration
RefreshTokenTTL time.Duration
CORSAllowedOrigins []string
CalendarProvider string
CalendarAPIBaseURL string
CalendarAPIKey string
CalendarCacheTTL time.Duration
UploadMaxSizeMB int
UploadAllowedMIMEs []string
StorageDriver string
StorageLocalPath string
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPassword string
SMTPFromEmail string
SMTPFromName string
}
func Load() Config {
return Config{
AppEnv: env("APP_ENV", "development"),
AppPublicURL: env("APP_PUBLIC_URL", "http://localhost:5173"),
AppTimezone: env("APP_TIMEZONE", "America/Sao_Paulo"),
HTTPAddr: env("HTTP_ADDR", ":8080"),
LogLevel: logLevel(env("LOG_LEVEL", "info")),
DatabaseURL: env("DATABASE_URL", "postgres://mira:mira@localhost:5432/mira?sslmode=disable"),
SuperAdminEmail: env("SUPER_ADMIN_EMAIL", ""),
SuperAdminPassword: env("SUPER_ADMIN_PASSWORD", ""),
JWTSecret: env("JWT_SECRET", ""),
AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15),
RefreshTokenTTL: hours("JWT_REFRESH_TOKEN_TTL_DAYS", 7*24),
CORSAllowedOrigins: list("CORS_ALLOWED_ORIGINS", "http://localhost:5173"),
CalendarProvider: env("CALENDAR_PROVIDER", "brasilapi"),
CalendarAPIBaseURL: env("CALENDAR_API_BASE_URL", "https://brasilapi.com.br"),
CalendarAPIKey: env("CALENDAR_API_KEY", ""),
CalendarCacheTTL: hours("CALENDAR_CACHE_TTL_HOURS", 24),
UploadMaxSizeMB: integer("UPLOAD_MAX_SIZE_MB", 10),
UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf"),
StorageDriver: env("STORAGE_DRIVER", "local"),
StorageLocalPath: env("STORAGE_LOCAL_PATH", "/var/lib/mira/uploads"),
SMTPHost: env("SMTP_HOST", ""),
SMTPPort: env("SMTP_PORT", "587"),
SMTPUser: env("SMTP_USER", ""),
SMTPPassword: env("SMTP_PASSWORD", ""),
SMTPFromEmail: env("SMTP_FROM_EMAIL", ""),
SMTPFromName: env("SMTP_FROM_NAME", "Mira"),
}
}
func env(key, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
func list(key, fallback string) []string {
raw := env(key, fallback)
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value != "" {
values = append(values, value)
}
}
return values
}
func integer(key string, fallback int) int {
value, err := strconv.Atoi(env(key, ""))
if err != nil {
return fallback
}
return value
}
func minutes(key string, fallback int) time.Duration {
return time.Duration(integer(key, fallback)) * time.Minute
}
func hours(key string, fallback int) time.Duration {
return time.Duration(integer(key, fallback)) * time.Hour
}
func logLevel(value string) slog.Level {
switch strings.ToLower(value) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,77 @@
package database
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create database pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (db *DB) Close() {
if db != nil && db.Pool != nil {
db.Pool.Close()
}
}
func (db *DB) Migrate(ctx context.Context, logger *slog.Logger, migrationsDir string) error {
files, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql"))
if err != nil {
return fmt.Errorf("list migrations: %w", err)
}
sort.Strings(files)
for _, file := range files {
sql, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("read migration %s: %w", file, err)
}
statement := strings.TrimSpace(string(sql))
if statement == "" {
continue
}
if _, err := db.Pool.Exec(ctx, statement); err != nil {
return fmt.Errorf("run migration %s: %w", file, err)
}
logger.Info("migration applied", "file", filepath.Base(file))
}
return nil
}

View File

@@ -0,0 +1,75 @@
package httpserver
import (
"log/slog"
"net/http"
"mira/backend/internal/auth"
"mira/backend/internal/calendar"
"mira/backend/internal/clients"
"mira/backend/internal/config"
"mira/backend/internal/database"
"mira/backend/internal/security"
"mira/backend/internal/users"
"github.com/gin-gonic/gin"
)
func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore users.Store, clientStore clients.Store, calendarStore calendar.Store) *gin.Engine {
if cfg.AppEnv == "production" {
gin.SetMode(gin.ReleaseMode)
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(security.Headers())
router.Use(security.CORS(cfg.CORSAllowedOrigins))
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
router.GET("/readyz", func(c *gin.Context) {
if err := db.Pool.Ping(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ready"})
})
tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL)
requireAuth := auth.RequireAuth(tokenService)
authRoutes := auth.NewRoutes(userStore, tokenService)
userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL)
clientRoutes := clients.NewRoutes(clientStore)
calendarRoutes := calendar.NewRoutes(calendarStore, calendar.NewBrasilAPIProvider(cfg.CalendarAPIBaseURL), calendar.NewFeriadosBrasilProvider(), calendar.RouteOptions{
UploadMaxSizeMB: cfg.UploadMaxSizeMB,
UploadAllowedMIMEs: cfg.UploadAllowedMIMEs,
StorageDriver: cfg.StorageDriver,
StorageLocalPath: cfg.StorageLocalPath,
})
api := router.Group("/api/v1")
authRoutes.Register(api.Group("/auth"))
api.GET("/me", requireAuth, func(c *gin.Context) {
claims, ok := auth.ClaimsFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
return
}
user, err := userStore.FindByID(c.Request.Context(), claims.UserID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"message": "Usuario nao encontrado."})
return
}
c.JSON(http.StatusOK, gin.H{"user": user})
})
userRoutes.Register(api.Group("/users"), requireAuth)
clientRoutes.Register(api.Group("/clients"), requireAuth)
calendarRoutes.Register(api.Group("/calendar"), requireAuth)
logger.Info("routes registered")
return router
}

View File

@@ -0,0 +1,43 @@
package security
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func Headers() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
c.Next()
}
}
func CORS(allowedOrigins []string) gin.HandlerFunc {
allowed := map[string]bool{}
for _, origin := range allowedOrigins {
allowed[strings.TrimSpace(origin)] = true
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if allowed[origin] {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@@ -0,0 +1,209 @@
package users
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrInvitationNotFound = errors.New("invitation not found")
type Invitation struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
ClientID *string `json:"client_id"`
ClientName *string `json:"client_name"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *time.Time `json:"accepted_at"`
CreatedBy *string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
InvitationURL string `json:"invitation_url,omitempty"`
}
type CreateInvitationInput struct {
Email string
Role string
ClientID string
TokenHash string
ExpiresAt time.Time
CreatedBy string
}
func NewInvitationToken() (string, string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", "", err
}
token := base64.RawURLEncoding.EncodeToString(bytes)
return token, HashInvitationToken(token), nil
}
func HashInvitationToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func (s Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO invitations (email, role, client_id, token_hash, expires_at, created_by)
VALUES (lower($1), $2, NULLIF($3, '')::uuid, $4, $5, NULLIF($6, '')::uuid)
RETURNING
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
`, input.Email, input.Role, input.ClientID, input.TokenHash, input.ExpiresAt, input.CreatedBy)
return scanInvitation(row)
}
func (s Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
rows, err := s.db.Query(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
clients.name,
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at
FROM invitations
LEFT JOIN clients ON clients.id = invitations.client_id
ORDER BY invitations.created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := []Invitation{}
for rows.Next() {
invitation, err := scanInvitation(rows)
if err != nil {
return nil, err
}
invitations = append(invitations, invitation)
}
return invitations, rows.Err()
}
func (s Store) AcceptInvitation(ctx context.Context, tokenHash string, name string, passwordHash string) (User, error) {
tx, err := s.db.Begin(ctx)
if err != nil {
return User{}, err
}
defer tx.Rollback(ctx)
var invitation Invitation
var rawTokenHash string
err = tx.QueryRow(ctx, `
SELECT
invitations.id::text,
invitations.email,
invitations.role,
invitations.client_id::text,
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
invitations.expires_at,
invitations.accepted_at,
invitations.created_by::text,
invitations.created_at,
invitations.token_hash
FROM invitations
WHERE invitations.token_hash = $1
AND invitations.accepted_at IS NULL
AND invitations.expires_at > now()
FOR UPDATE
`, tokenHash).Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
&rawTokenHash,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrInvitationNotFound
}
if err != nil {
return User{}, err
}
user, err := scanUser(tx.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, $2, $3, $4, 'active')
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, invitation.Email, name, passwordHash, invitation.Role))
if err != nil {
return User{}, err
}
if invitation.ClientID != nil {
if _, err := tx.Exec(ctx, `
INSERT INTO client_users (client_id, user_id)
VALUES ($1::uuid, $2::uuid)
ON CONFLICT DO NOTHING
`, *invitation.ClientID, user.ID); err != nil {
return User{}, err
}
}
if _, err := tx.Exec(ctx, `
UPDATE invitations
SET accepted_at = now()
WHERE token_hash = $1
`, rawTokenHash); err != nil {
return User{}, err
}
if err := tx.Commit(ctx); err != nil {
return User{}, err
}
return user, nil
}
type invitationScanner interface {
Scan(dest ...any) error
}
func scanInvitation(row invitationScanner) (Invitation, error) {
var invitation Invitation
err := row.Scan(
&invitation.ID,
&invitation.Email,
&invitation.Role,
&invitation.ClientID,
&invitation.ClientName,
&invitation.ExpiresAt,
&invitation.AcceptedAt,
&invitation.CreatedBy,
&invitation.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return Invitation{}, ErrInvitationNotFound
}
if err != nil {
return Invitation{}, err
}
return invitation, nil
}

View File

@@ -0,0 +1,20 @@
package users
import "time"
const (
RoleSuperAdmin = "super_admin"
RoleAgencyUser = "agency_user"
RoleClientViewer = "client_viewer"
)
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
PasswordHash string `json:"-"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@@ -0,0 +1,118 @@
package users
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Routes struct {
store Store
appPublicURL string
}
func NewRoutes(store Store, appPublicURL string) Routes {
return Routes{
store: store,
appPublicURL: strings.TrimRight(appPublicURL, "/"),
}
}
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
router.Use(requireAuth)
router.GET("", r.list)
router.GET("/invitations", r.listInvitations)
router.POST("/invitations", r.createInvitation)
}
func (r Routes) list(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
users, err := r.store.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar usuarios."})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func (r Routes) listInvitations(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
invitations, err := r.store.ListInvitations(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar convites."})
return
}
c.JSON(http.StatusOK, gin.H{"invitations": invitations})
}
type createInvitationRequest struct {
Email string `json:"email" binding:"required,email"`
Role string `json:"role" binding:"required"`
ClientID string `json:"client_id"`
}
func (r Routes) createInvitation(c *gin.Context) {
if c.GetString("auth_role") != RoleSuperAdmin {
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
return
}
var input createInvitationRequest
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados do convite."})
return
}
role := strings.TrimSpace(input.Role)
if role != RoleAgencyUser && role != RoleClientViewer {
c.JSON(http.StatusBadRequest, gin.H{"message": "Perfil invalido para convite."})
return
}
if role == RoleClientViewer && strings.TrimSpace(input.ClientID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"message": "Convites de cliente precisam de um cliente vinculado."})
return
}
token, tokenHash, err := NewInvitationToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel gerar o convite."})
return
}
invitation, err := r.store.CreateInvitation(c.Request.Context(), CreateInvitationInput{
Email: strings.TrimSpace(input.Email),
Role: role,
ClientID: strings.TrimSpace(input.ClientID),
TokenHash: tokenHash,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
CreatedBy: c.GetString("auth_user_id"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o convite."})
return
}
invitation.InvitationURL = r.invitationURL(token)
c.JSON(http.StatusCreated, gin.H{"invitation": invitation})
}
func (r Routes) invitationURL(token string) string {
if r.appPublicURL == "" {
return "/accept-invitation?token=" + token
}
return r.appPublicURL + "/accept-invitation?token=" + token
}

View File

@@ -0,0 +1,100 @@
package users
import (
"context"
"errors"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("user not found")
type Store struct {
db *pgxpool.Pool
}
func NewStore(db *pgxpool.Pool) Store {
return Store{db: db}
}
func (s Store) FindByEmail(ctx context.Context, email string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE lower(email) = lower($1)
`, strings.TrimSpace(email))
return scanUser(row)
}
func (s Store) FindByID(ctx context.Context, id string) (User, error) {
row := s.db.QueryRow(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
WHERE id = $1
`, id)
return scanUser(row)
}
func (s Store) List(ctx context.Context) ([]User, error) {
rows, err := s.db.Query(ctx, `
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
FROM users
ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
users := []User{}
for rows.Next() {
user, err := scanUser(rows)
if err != nil {
return nil, err
}
users = append(users, user)
}
return users, rows.Err()
}
func (s Store) UpsertSuperAdmin(ctx context.Context, email string, passwordHash string) (User, error) {
row := s.db.QueryRow(ctx, `
INSERT INTO users (email, name, password_hash, role, status)
VALUES ($1, 'Super Admin', $2, 'super_admin', 'active')
ON CONFLICT (email)
DO UPDATE SET
password_hash = EXCLUDED.password_hash,
role = 'super_admin',
status = 'active',
updated_at = now()
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
`, strings.TrimSpace(email), passwordHash)
return scanUser(row)
}
func scanUser(row pgx.Row) (User, error) {
var user User
err := row.Scan(
&user.ID,
&user.Email,
&user.Name,
&user.PasswordHash,
&user.Role,
&user.Status,
&user.CreatedAt,
&user.UpdatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, err
}
return user, nil
}

View File

@@ -0,0 +1,114 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('super_admin', 'agency_user', 'client_viewer')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS client_users (
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (client_id, user_id)
);
CREATE TABLE IF NOT EXISTS invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('agency_user', 'client_viewer')),
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
accepted_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS holidays (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
date DATE NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'official_holiday',
source TEXT NOT NULL DEFAULT 'brasilapi',
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (date, source, name)
);
CREATE TABLE IF NOT EXISTS commemorative_dates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
date DATE NOT NULL,
recurs_annually BOOLEAN NOT NULL DEFAULT false,
visibility TEXT NOT NULL DEFAULT 'agency' CHECK (visibility IN ('agency', 'client')),
category TEXT NOT NULL DEFAULT 'custom',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS calendar_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
content_type TEXT NOT NULL DEFAULT 'post',
status TEXT NOT NULL DEFAULT 'rascunho' CHECK (
status IN ('rascunho', 'planejado', 'em_producao', 'em_revisao', 'aprovado', 'publicado', 'cancelado')
),
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
scheduled_date DATE NOT NULL,
scheduled_at TIMESTAMPTZ,
copy_text TEXT NOT NULL DEFAULT '',
internal_notes TEXT NOT NULL DEFAULT '',
client_notes TEXT NOT NULL DEFAULT '',
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS calendar_item_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
calendar_item_id UUID NOT NULL REFERENCES calendar_items(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL,
stored_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
storage_driver TEXT NOT NULL,
storage_path TEXT NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE INDEX IF NOT EXISTS idx_calendar_items_client_date ON calendar_items (client_id, scheduled_date);
CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays (date);
CREATE INDEX IF NOT EXISTS idx_commemorative_dates_client_date ON commemorative_dates (client_id, date);

View File

@@ -0,0 +1,6 @@
ALTER TABLE clients
ADD COLUMN IF NOT EXISTS website_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS instagram_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS facebook_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS linkedin_url TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,3 @@
CREATE UNIQUE INDEX IF NOT EXISTS idx_commemorative_dates_global_unique
ON commemorative_dates (date, name, category)
WHERE client_id IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE clients
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#f97316';

69
docker-compose.yml Normal file
View File

@@ -0,0 +1,69 @@
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
APP_ENV: ${APP_ENV}
APP_PUBLIC_URL: ${APP_PUBLIC_URL}
APP_TIMEZONE: ${APP_TIMEZONE}
HTTP_ADDR: ${HTTP_ADDR}
LOG_LEVEL: ${LOG_LEVEL}
SUPER_ADMIN_EMAIL: ${SUPER_ADMIN_EMAIL}
SUPER_ADMIN_PASSWORD: ${SUPER_ADMIN_PASSWORD}
DATABASE_URL: ${DATABASE_URL}
JWT_SECRET: ${JWT_SECRET}
JWT_ACCESS_TOKEN_TTL_MINUTES: ${JWT_ACCESS_TOKEN_TTL_MINUTES}
JWT_REFRESH_TOKEN_TTL_DAYS: ${JWT_REFRESH_TOKEN_TTL_DAYS}
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
CALENDAR_PROVIDER: ${CALENDAR_PROVIDER}
CALENDAR_API_BASE_URL: ${CALENDAR_API_BASE_URL}
CALENDAR_API_KEY: ${CALENDAR_API_KEY}
CALENDAR_CACHE_TTL_HOURS: ${CALENDAR_CACHE_TTL_HOURS}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
SMTP_PASSWORD: ${SMTP_PASSWORD}
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL}
SMTP_FROM_NAME: ${SMTP_FROM_NAME}
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
UPLOAD_ALLOWED_MIME_TYPES: ${UPLOAD_ALLOWED_MIME_TYPES}
STORAGE_DRIVER: ${STORAGE_DRIVER}
STORAGE_LOCAL_PATH: ${STORAGE_LOCAL_PATH}
volumes:
- uploads:/var/lib/mira/uploads
ports:
- "${BACKEND_PORT:-8080}:8080"
frontend:
build:
context: ./frontend
args:
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-5173}:80"
volumes:
postgres_data:
uploads:

19
docs/api.md Normal file
View File

@@ -0,0 +1,19 @@
# API Notes
Base path: `/api/v1`.
Current scaffold routes:
- `GET /healthz`
- `GET /readyz`
- `POST /api/v1/auth/login`
- `POST /api/v1/auth/logout`
- `POST /api/v1/auth/refresh`
- `GET /api/v1/users`
- `POST /api/v1/users/invitations`
- `GET /api/v1/clients`
- `POST /api/v1/clients`
- `GET /api/v1/clients/:clientId`
- `PATCH /api/v1/clients/:clientId`
- `GET /api/v1/calendar/week`
- `GET /api/v1/calendar/holidays?year=2026`

36
docs/architecture.md Normal file
View File

@@ -0,0 +1,36 @@
# Architecture Notes
## Initial Decisions
- PostgreSQL is the system of record.
- BrasilAPI is the initial provider for Brazilian national holidays.
- Calendar data from external providers must be cached or persisted before production use.
- Regional holidays and custom commemorative dates are first-party data managed inside the app.
- Client users are read-only in the MVP.
- Agency users can only access assigned clients unless they are super admins.
## Backend Boundaries
- `auth`: login, session/token handling, password hashing, invitation acceptance.
- `users`: user management and invitations.
- `clients`: client records and user-client access.
- `calendar`: provider integrations, holiday sync, weekly dashboard, calendar views.
- `security`: headers, CORS, rate limiting, auth middleware.
- `database`: migrations, connection handling, transactions.
## Frontend Boundaries
- Authenticated app shell.
- Weekly dashboard.
- Client list.
- Client calendar.
- Day detail.
- User and invitation management.
- Custom dates.
## Data Rules
- Calendar-only dates should be stored as `date`.
- Instants with time should be stored in UTC.
- The operational timezone is `America/Sao_Paulo`.
- Audit logs should record administrative and content changes.

26
docs/security.md Normal file
View File

@@ -0,0 +1,26 @@
# Security Notes
The application must follow OWASP guidance throughout implementation.
## Required Controls
- Strong password hashing.
- Login rate limiting.
- Backend role and client-scope authorization.
- Restrictive CORS.
- HTTP security headers.
- Parameterized database queries.
- Safe upload handling.
- Protected attachment access.
- Invitation tokens with expiration and single use.
- Audit logs for sensitive changes.
- No secrets in the repository.
## Upload Rules
- Limit file size.
- Restrict MIME types.
- Validate actual file content.
- Rename files before storage.
- Do not execute or directly expose uploaded files.
- Check client access before serving files.

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
node_modules
dist
*.log

19
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS build
WORKDIR /app
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

25
frontend/components.json Normal file
View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f97316" />
<title>Mira</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
frontend/metadata.json Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "",
"description": "",
"requestFramePermissions": [],
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
}

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

7720
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
frontend/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "mira-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=5173 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
"lint": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/geist": "^5.2.9",
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"next-themes": "^0.4.6",
"react": "^19.0.1",
"react-day-picker": "^10.0.1",
"react-dom": "^19.0.1",
"react-router-dom": "^7.16.0",
"shadcn": "^4.10.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"vite": "^6.2.3"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3"
}
}

1
frontend/public/.gitkeep Normal file
View File

@@ -0,0 +1 @@

200
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import { ThemeProvider } from "./components/theme-provider";
import { LoginView } from "./views/LoginView";
import { AcceptInvitationView } from "./views/AcceptInvitationView";
import { DashboardView } from "./views/DashboardView";
import { CalendarView } from "./views/CalendarView";
import { UsersView } from "./views/UsersView";
import { ClientsView } from "./views/ClientsView";
import { ClientDashboardView } from "./views/ClientDashboardView";
import { PostDetailView } from "./views/PostDetailView";
import { Topbar } from "./components/layout/Topbar";
import { Sidebar } from "./components/layout/Sidebar";
import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth";
import { listClients, type Client } from "@/lib/clients";
export default function App() {
const [user, setUser] = useState<AuthUser | null>(null);
const [clients, setClients] = useState<Client[]>([]);
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
const [isCheckingSession, setIsCheckingSession] = useState(true);
const [clientsError, setClientsError] = useState("");
const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users'
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
const invitationToken =
window.location.pathname === "/accept-invitation"
? new URLSearchParams(window.location.search).get("token")
: null;
async function refreshClients(token = getStoredToken()) {
if (!token) return;
try {
setClientsError("");
const loadedClients = await listClients(token);
setClients(loadedClients);
setSelectedClientId((current) => {
if (!current) return null;
return loadedClients.some((client) => client.id === current) ? current : null;
});
} catch (err) {
setClientsError(err instanceof Error ? err.message : "Nao foi possivel carregar clientes.");
}
}
useEffect(() => {
const token = getStoredToken();
if (!token) {
setIsCheckingSession(false);
return;
}
loadCurrentUser(token)
.then((loadedUser) => {
setUser(loadedUser);
return refreshClients(token);
})
.catch(() => {
clearStoredToken();
setUser(null);
})
.finally(() => setIsCheckingSession(false));
}, []);
function handleLogout() {
clearStoredToken();
setUser(null);
setClients([]);
setSelectedClientId(null);
setCurrentView("dashboard");
setSelectedPostId(null);
}
function handleLogin(loadedUser: AuthUser) {
setUser(loadedUser);
void refreshClients();
}
function handleInvitationAccepted(loadedUser: AuthUser) {
setUser(loadedUser);
void refreshClients();
}
function handleClientCreated(client: Client) {
setClients((current) => [...current, client].sort((a, b) => a.name.localeCompare(b.name)));
setSelectedClientId(client.id);
}
function handleClientUpdated(client: Client) {
setClients((current) => current.map((item) => (item.id === client.id ? client : item)).sort((a, b) => a.name.localeCompare(b.name)));
}
function handleClientDeleted(clientId: string) {
setClients((current) => current.filter((client) => client.id !== clientId));
setSelectedClientId((current) => (current === clientId ? null : current));
setCurrentView((current) => (current === "client-dashboard" && selectedClientId === clientId ? "clients" : current));
}
function handleClientDashboardSelect(clientId: string) {
setSelectedClientId(clientId);
setCurrentView("client-dashboard");
}
function handlePostSelect(postId: string) {
setSelectedPostId(postId);
setCurrentView("post-detail");
}
if (isCheckingSession) {
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
<div className="text-sm text-muted-foreground">Carregando sessao...</div>
</div>
</ThemeProvider>
);
}
if (!user) {
if (invitationToken) {
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<AcceptInvitationView token={invitationToken} onAccepted={handleInvitationAccepted} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<LoginView onLogin={handleLogin} />
</ThemeProvider>
);
}
return (
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
<div className="min-h-screen bg-background text-foreground flex flex-col md:flex-row">
{/* Mobile Topbar for responsive */}
<div className="md:hidden">
<Topbar
onViewChange={setCurrentView}
currentView={currentView}
user={user}
onLogout={handleLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={handleClientDashboardSelect}
clientsError={clientsError}
/>
</div>
{/* Desktop Sidebar */}
<div className="hidden md:flex sticky top-0 h-screen">
<Sidebar
onViewChange={setCurrentView}
currentView={currentView}
user={user}
onLogout={handleLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={handleClientDashboardSelect}
clientsError={clientsError}
/>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-x-hidden">
<main className="p-4 md:p-8 max-w-7xl mx-auto w-full">
{currentView === "dashboard" && (
<DashboardView onViewChange={setCurrentView} onPostSelect={handlePostSelect} />
)}
{currentView === "clients" && (
<ClientsView
clients={clients}
onClientCreated={handleClientCreated}
onClientUpdated={handleClientUpdated}
onClientDeleted={handleClientDeleted}
onClientSelect={setSelectedClientId}
/>
)}
{currentView === "client-dashboard" && selectedClientId && (
<ClientDashboardView
client={clients.find((client) => client.id === selectedClientId) ?? null}
selectedClientId={selectedClientId}
onViewChange={setCurrentView}
onPostSelect={handlePostSelect}
/>
)}
{currentView === "post-detail" && selectedPostId && (
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} />
)}
{currentView === "calendar" && (
<CalendarView clients={clients} selectedClientId={selectedClientId} user={user} onPostSelect={handlePostSelect} />
)}
{currentView === "users" && <UsersView clients={clients} />}
</main>
</div>
</div>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,153 @@
import { LayoutDashboard, CalendarDays, Users, Settings, LogOut, Search, Building2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ModeToggle } from "@/components/mode-toggle";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
import type { AuthUser } from "@/lib/auth";
import type { Client } from "@/lib/clients";
import { useMemo, useState } from "react";
type NavProps = {
currentView: string;
onViewChange: (target: string) => void;
user: AuthUser;
onLogout: () => void;
clients: Client[];
selectedClientId: string | null;
onClientSelect: (clientId: string) => void;
clientsError: string;
};
export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
const [clientSearch, setClientSearch] = useState("");
const navItems = [
{ id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard },
{ id: "clients", label: "Clientes", icon: Building2 },
{ id: "calendar", label: "Calendário", icon: CalendarDays },
{ id: "users", label: "Pessoas", icon: Users },
];
const filteredClients = useMemo(() => {
const term = clientSearch.trim().toLowerCase();
if (!term) return clients;
return clients.filter((client) => client.name.toLowerCase().includes(term));
}, [clients, clientSearch]);
return (
<aside className="w-64 border-r border-border/40 bg-card h-screen flex flex-col sticky top-0">
<div className="h-16 flex items-center px-6 border-b border-border/40">
<div className="h-8 w-8 bg-primary rounded-md flex items-center justify-center mr-3">
<span className="text-primary-foreground font-bold leading-none">M</span>
</div>
<span className="font-semibold text-lg tracking-tight">Mira</span>
</div>
<div className="p-4 border-b border-border/40">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar clientes..."
className="pl-9 h-9 bg-muted/50 border-none shadow-none focus-visible:ring-1"
value={clientSearch}
onChange={(event) => setClientSearch(event.target.value)}
/>
</div>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Agência</div>
{navItems.map((item) => {
const Icon = item.icon;
const isActive = currentView === item.id;
return (
<button
key={item.id}
onClick={() => onViewChange(item.id)}
className={cn(
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
);
})}
<div className="pt-5">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Dashboards</div>
{clientsError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{clientsError}
</div>
)}
{!clientsError && filteredClients.length === 0 && (
<div className="rounded-md border border-dashed border-border/60 px-3 py-3 text-xs text-muted-foreground">
{clients.length === 0 ? "Nenhum cliente cadastrado." : "Nenhum cliente encontrado."}
</div>
)}
<div className="space-y-1">
{filteredClients.map((client) => {
const isSelected = currentView === "client-dashboard" && selectedClientId === client.id;
return (
<button
key={client.id}
onClick={() => onClientSelect(client.id)}
className={cn(
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
isSelected
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<Building2 className="h-4 w-4" />
<span className="truncate">{client.name}</span>
</button>
);
})}
</div>
</div>
</nav>
<div className="p-4 border-t border-border/40 space-y-4">
<div className="flex items-center justify-between">
<ModeToggle />
<Button variant="ghost" size="icon" className="text-muted-foreground h-9 w-9">
<Settings className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9 border border-border/50">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none truncate">{user.name}</p>
<p className="text-xs text-muted-foreground truncate mt-1">{roleLabel(user.role)}</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={onLogout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</aside>
);
}
function roleLabel(role: AuthUser["role"]) {
if (role === "super_admin") return "Super admin";
if (role === "agency_user") return "Agencia";
return "Cliente";
}
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "M";
}

View File

@@ -0,0 +1,74 @@
import { LogOut, Menu } from "lucide-react";
import { buttonVariants } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Sidebar } from "./Sidebar";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import type { AuthUser } from "@/lib/auth";
import type { Client } from "@/lib/clients";
type NavProps = {
currentView: string;
onViewChange: (target: string) => void;
user: AuthUser;
onLogout: () => void;
clients: Client[];
selectedClientId: string | null;
onClientSelect: (clientId: string) => void;
clientsError: string;
};
export function Topbar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
return (
<header className="h-14 border-b border-border/40 bg-card flex items-center justify-between px-4 sticky top-0 z-30">
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger className={buttonVariants({ variant: "ghost", size: "icon", className: "-ml-2 h-9 w-9" })}>
<Menu className="h-5 w-5" />
<span className="sr-only">Toggle Menu</span>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle>Navigation Menu</SheetTitle>
</SheetHeader>
<Sidebar
currentView={currentView}
onViewChange={onViewChange}
user={user}
onLogout={onLogout}
clients={clients}
selectedClientId={selectedClientId}
onClientSelect={onClientSelect}
clientsError={clientsError}
/>
</SheetContent>
</Sheet>
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-[10px] leading-none">M</span>
</div>
<span className="font-semibold text-base tracking-tight">Mira</span>
</div>
</div>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8 border border-border/50">
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
</Avatar>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={onLogout}>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
);
}
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || "M";
}

View File

@@ -0,0 +1,35 @@
import { Moon, Sun } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider"
export function ModeToggle() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger className={buttonVariants({ variant: "ghost", size: "icon" })}>
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Claro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Escuro
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Sistema
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
type Theme = "dark" | "light" | "system"
type ThemeProviderProps = {
children: ReactNode
defaultTheme?: Theme
storageKey?: string
}
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
useEffect(() => {
const root = window.document.documentElement
root.classList.remove("light", "dark")
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
return context
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,219 @@
import * as React from "react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
type Locale,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"relative flex flex-col gap-4 md:flex-row",
defaultClassNames.months
),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative rounded-(--cell-radius)",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"font-medium select-none",
captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn(
"w-(--cell-size) select-none",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon className={cn("size-4", className)} {...props} />
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,27 @@
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,50 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
import type { CSSProperties } from "react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

138
frontend/src/index.css Normal file
View File

@@ -0,0 +1,138 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(0.98 0.005 60); /* Off-white */
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.65 0.22 45); /* Orange */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.15 0.005 45); /* Near black/charcoal */
--foreground: oklch(0.985 0 0);
--card: oklch(0.20 0.006 45);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.20 0.006 45);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.65 0.22 45); /* Orange */
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.65 0.22 45);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
a[href],
select,
input[type="checkbox"],
input[type="file"] {
cursor: pointer;
}
}

65
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,65 @@
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1";
type RequestOptions = RequestInit & {
token?: string | null;
};
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
if (options.token) {
headers.set("Authorization", `Bearer ${options.token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String(data.message)
: "Nao foi possivel concluir a solicitacao.";
throw new ApiError(message, response.status);
}
return data as T;
}
export async function apiFormRequest<T>(path: string, formData: FormData, token?: string | null): Promise<T> {
const headers = new Headers();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
headers,
body: formData,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String(data.message)
: "Nao foi possivel concluir a solicitacao.";
throw new ApiError(message, response.status);
}
return data as T;
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}

62
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,62 @@
import { apiRequest } from "@/lib/api";
const TOKEN_STORAGE_KEY = "mira_access_token";
export type AuthUser = {
id: string;
email: string;
name: string;
role: "super_admin" | "agency_user" | "client_viewer";
status: string;
};
type LoginResponse = {
access_token: string;
token_type: "Bearer";
user: AuthUser;
};
type MeResponse = {
user: AuthUser;
};
export function getStoredToken() {
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
export function storeToken(token: string) {
localStorage.setItem(TOKEN_STORAGE_KEY, token);
}
export function clearStoredToken() {
localStorage.removeItem(TOKEN_STORAGE_KEY);
}
export async function login(email: string, password: string) {
const response = await apiRequest<LoginResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
storeToken(response.access_token);
return response;
}
export async function acceptInvitation(token: string, name: string, password: string) {
const response = await apiRequest<LoginResponse>("/auth/invitations/accept", {
method: "POST",
body: JSON.stringify({ token, name, password }),
});
storeToken(response.access_token);
return response;
}
export async function loadCurrentUser(token: string) {
const response = await apiRequest<MeResponse>("/me", {
method: "GET",
token,
});
return response.user;
}

View File

@@ -0,0 +1,256 @@
import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api";
export type Holiday = {
id: string;
name: string;
date: string;
type: string;
source: string;
};
export type CustomDate = {
id: string;
client_id: string | null;
name: string;
description: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
category: string;
created_at: string;
updated_at: string;
};
export type CalendarItem = {
id: string;
client_id: string;
client_name: string;
title: string;
description: string;
content_type: string;
status: "rascunho" | "planejado" | "em_producao" | "em_revisao" | "aprovado" | "publicado" | "cancelado";
owner_id: string | null;
scheduled_date: string;
scheduled_at: string | null;
copy_text: string;
internal_notes: string;
client_notes: string;
created_at: string;
updated_at: string;
};
export type Attachment = {
id: string;
calendar_item_id: string;
original_filename: string;
stored_filename: string;
mime_type: string;
size_bytes: number;
storage_driver: string;
created_by: string | null;
created_at: string;
};
export type CreateCustomDateInput = {
client_id?: string;
name: string;
description?: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
category: string;
};
export type UpdateCustomDateInput = {
client_id?: string;
name: string;
description?: string;
date: string;
recurs_annually: boolean;
visibility: "agency" | "client";
};
export type CreateCalendarItemInput = {
client_id: string;
title: string;
description?: string;
content_type: string;
status: CalendarItem["status"];
scheduled_date: string;
copy_text?: string;
internal_notes?: string;
client_notes?: string;
};
export type UpdateCalendarItemInput = Partial<{
title: string;
description: string;
status: CalendarItem["status"];
scheduled_date: string;
copy_text: string;
internal_notes: string;
client_notes: string;
}>;
type HolidaysResponse = {
holidays: Holiday[];
};
type CustomDatesResponse = {
custom_dates: CustomDate[];
};
type CustomDateResponse = {
custom_date: CustomDate;
};
type CalendarItemsResponse = {
items: CalendarItem[];
};
type CalendarItemResponse = {
item: CalendarItem;
};
type AttachmentsResponse = {
attachments: Attachment[];
};
type AttachmentResponse = {
attachment: Attachment;
};
export async function listHolidays(token: string, year: number) {
const response = await apiRequest<HolidaysResponse>(`/calendar/holidays?year=${year}`, {
method: "GET",
token,
});
return response.holidays;
}
export async function listCustomDates(token: string, year: number, clientId?: string | null) {
const params = new URLSearchParams({ year: String(year) });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CustomDatesResponse>(`/calendar/custom-dates?${params.toString()}`, {
method: "GET",
token,
});
return response.custom_dates;
}
export async function createCustomDate(token: string, input: CreateCustomDateInput) {
const response = await apiRequest<CustomDateResponse>("/calendar/custom-dates", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.custom_date;
}
export async function updateCustomDate(token: string, customDateId: string, input: UpdateCustomDateInput) {
const response = await apiRequest<CustomDateResponse>(`/calendar/custom-dates/${customDateId}`, {
method: "PATCH",
token,
body: JSON.stringify(input),
});
return response.custom_date;
}
export async function deleteCustomDate(token: string, customDateId: string) {
await apiRequest<{ message: string }>(`/calendar/custom-dates/${customDateId}`, {
method: "DELETE",
token,
});
}
export async function listCalendarItems(token: string, year: number, clientId?: string | null) {
const params = new URLSearchParams({ year: String(year) });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
method: "GET",
token,
});
return response.items;
}
export async function listCalendarItemsByRange(token: string, from: string, to: string, clientId?: string | null) {
const params = new URLSearchParams({ from, to });
if (clientId) {
params.set("client_id", clientId);
}
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
method: "GET",
token,
});
return response.items;
}
export async function createCalendarItem(token: string, input: CreateCalendarItemInput) {
const response = await apiRequest<CalendarItemResponse>("/calendar/items", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.item;
}
export async function getCalendarItem(token: string, itemId: string) {
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
method: "GET",
token,
});
return response.item;
}
export async function updateCalendarItem(token: string, itemId: string, input: UpdateCalendarItemInput) {
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
method: "PATCH",
token,
body: JSON.stringify(input),
});
return response.item;
}
export async function cancelCalendarItem(token: string, itemId: string) {
await apiRequest<{ message: string }>(`/calendar/items/${itemId}`, {
method: "DELETE",
token,
});
}
export async function listAttachments(token: string, itemId: string) {
const response = await apiRequest<AttachmentsResponse>(`/calendar/items/${itemId}/attachments`, {
method: "GET",
token,
});
return response.attachments;
}
export async function uploadAttachment(token: string, itemId: string, file: File) {
const formData = new FormData();
formData.set("file", file);
const response = await apiFormRequest<AttachmentResponse>(`/calendar/items/${itemId}/attachments`, formData, token);
return response.attachment;
}
export function attachmentDownloadURL(attachmentId: string) {
return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`;
}

View File

@@ -0,0 +1,79 @@
import { apiRequest } from "@/lib/api";
export type Client = {
id: string;
name: string;
slug: string;
status: "active" | "archived";
website_url: string;
instagram_url: string;
facebook_url: string;
linkedin_url: string;
color: string;
notes: string;
created_at: string;
updated_at: string;
};
type ClientsResponse = {
clients: Client[];
};
type ClientResponse = {
client: Client;
};
export async function listClients(token: string) {
const response = await apiRequest<ClientsResponse>("/clients", {
method: "GET",
token,
});
return response.clients;
}
export async function createClient(token: string, name: string) {
const response = await apiRequest<ClientResponse>("/clients", {
method: "POST",
token,
body: JSON.stringify({ name }),
});
return response.client;
}
export async function updateClient(token: string, client: Client) {
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
method: "PATCH",
token,
body: JSON.stringify({
name: client.name,
status: client.status,
website_url: client.website_url,
instagram_url: client.instagram_url,
facebook_url: client.facebook_url,
linkedin_url: client.linkedin_url,
color: client.color,
notes: client.notes,
}),
});
return response.client;
}
export async function archiveClient(token: string, client: Client) {
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
method: "PATCH",
token,
body: JSON.stringify({ ...client, status: "archived" }),
});
return response.client;
}
export async function deleteClient(token: string, clientId: string) {
await apiRequest<{ message: string }>(`/clients/${clientId}`, {
method: "DELETE",
token,
});
}

54
frontend/src/lib/users.ts Normal file
View File

@@ -0,0 +1,54 @@
import { apiRequest } from "@/lib/api";
import type { AuthUser } from "@/lib/auth";
export type Invitation = {
id: string;
email: string;
role: "agency_user" | "client_viewer";
client_id: string | null;
client_name: string | null;
expires_at: string;
accepted_at: string | null;
created_at: string;
invitation_url?: string;
};
type UsersResponse = {
users: AuthUser[];
};
type InvitationsResponse = {
invitations: Invitation[];
};
type InvitationResponse = {
invitation: Invitation;
};
export async function listUsers(token: string) {
const response = await apiRequest<UsersResponse>("/users", {
method: "GET",
token,
});
return response.users;
}
export async function listInvitations(token: string) {
const response = await apiRequest<InvitationsResponse>("/users/invitations", {
method: "GET",
token,
});
return response.invitations;
}
export async function createInvitation(token: string, input: { email: string; role: Invitation["role"]; client_id?: string }) {
const response = await apiRequest<InvitationResponse>("/users/invitations", {
method: "POST",
token,
body: JSON.stringify(input),
});
return response.invitation;
}

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))
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,87 @@
import { useState, type FormEvent } from "react";
import { ArrowRight, CalendarDays } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { acceptInvitation, type AuthUser } from "@/lib/auth";
type AcceptInvitationViewProps = {
token: string;
onAccepted: (user: AuthUser) => void;
};
export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) {
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setError("");
try {
const response = await acceptInvitation(token, name, password);
onAccepted(response.user);
window.history.replaceState({}, "", "/");
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível aceitar o convite.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-background text-foreground flex items-center justify-center p-6">
<div className="w-full max-w-md space-y-8">
<div className="space-y-4 text-center">
<div className="h-12 w-12 rounded-xl bg-primary text-primary-foreground flex items-center justify-center mx-auto">
<CalendarDays className="h-6 w-6" />
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Aceitar convite</h1>
<p className="text-sm text-muted-foreground mt-2">Crie sua senha para acessar o Mira.</p>
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid gap-2">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Seu nome"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-password">Senha</Label>
<Input
id="invite-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Mínimo de 12 caracteres"
minLength={12}
required
/>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Ativando..." : "Entrar no Mira"}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,249 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, CalendarDays, CheckCircle2, Clock, ExternalLink, FileText, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getStoredToken } from "@/lib/auth";
import {
listCalendarItemsByRange,
listCustomDates,
listHolidays,
type CalendarItem,
type CustomDate,
type Holiday,
} from "@/lib/calendar";
import type { Client } from "@/lib/clients";
type ClientDashboardViewProps = {
client: Client | null;
selectedClientId: string;
onViewChange: (view: string) => void;
onPostSelect: (postId: string) => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
const longDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
export function ClientDashboardView({ client, selectedClientId, onViewChange, onPostSelect }: ClientDashboardViewProps) {
const today = useMemo(() => new Date(), []);
const monthStart = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
const monthEnd = useMemo(() => new Date(today.getFullYear(), today.getMonth() + 1, 0), [today]);
const [items, setItems] = useState<CalendarItem[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const monthStartValue = formatDateValue(monthStart);
const monthEndValue = formatDateValue(monthEnd);
const todayValue = formatDateValue(today);
const year = today.getFullYear();
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([
listCalendarItemsByRange(token, monthStartValue, monthEndValue, selectedClientId),
listHolidays(token, year),
listCustomDates(token, year, selectedClientId),
])
.then(([loadedItems, loadedHolidays, loadedCustomDates]) => {
setItems(loadedItems);
setHolidays(loadedHolidays);
setCustomDates(loadedCustomDates);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard do cliente.");
})
.finally(() => setIsLoading(false));
}, [monthEndValue, monthStartValue, selectedClientId, year]);
const visibleItems = items
.filter((item) => item.status !== "cancelado")
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
const upcomingItems = visibleItems.filter((item) => item.scheduled_date >= todayValue);
const reviewItems = visibleItems.filter((item) => item.status === "em_revisao");
const approvedItems = visibleItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
const dates = [...holidays, ...customDates]
.filter((date) => date.date >= monthStartValue && date.date <= monthEndValue)
.sort((a, b) => a.date.localeCompare(b.date));
if (!client) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Cliente não encontrado.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dashboard de {client.name}</h1>
<Badge variant="outline">{client.status === "archived" ? "Arquivado" : "Ativo"}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Visão do cliente entre {shortDateFormatter.format(monthStart)} e {shortDateFormatter.format(monthEnd)}.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<CalendarDays className="mr-2 h-4 w-4" />
Calendário
</Button>
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<Plus className="mr-2 h-4 w-4" />
Novo Post
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<MetricCard title="Posts do mês" value={visibleItems.length} detail={`${upcomingItems.length} próximos`} />
<MetricCard title="Em aprovação" value={reviewItems.length} detail="Aguardando retorno" />
<MetricCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
<MetricCard title="Datas do mês" value={dates.length} detail="Feriados e datas úteis" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
Linha do tempo de posts
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{isLoading && <p className="text-sm text-muted-foreground py-6">Carregando posts...</p>}
{!isLoading && visibleItems.length === 0 && (
<p className="text-sm text-muted-foreground py-6">Nenhum post previsto para este mês.</p>
)}
{visibleItems.map((item) => (
<button key={item.id} type="button" className="w-full rounded-lg border border-border/60 bg-card p-4 text-left hover:bg-muted/30" onClick={() => onPostSelect(item.id)}>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300">
{statusLabels[item.status]}
</Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{longDateFormatter.format(parseDateValue(item.scheduled_date))}
</span>
</div>
<h2 className="font-medium text-sm text-foreground">{item.title}</h2>
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
{item.client_notes && <p className="text-sm text-muted-foreground">{item.client_notes}</p>}
</div>
{item.status === "aprovado" || item.status === "publicado" ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary" />
) : (
<AlertCircle className="h-5 w-5 shrink-0 text-orange-500" />
)}
</div>
</button>
))}
</CardContent>
</Card>
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Datas relevantes
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-3">
{dates.map((date) => (
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<p className="font-medium truncate">{date.name}</p>
<p className="text-xs text-muted-foreground">
{"source" in date ? "Feriado nacional" : date.category === "feriados-brasil" ? "Data comemorativa" : "Data da agência"}
</p>
</div>
<Badge variant="outline" className="font-mono bg-muted/40">
{shortDateFormatter.format(parseDateValue(date.date))}
</Badge>
</div>
))}
{dates.length === 0 && (
<p className="text-sm text-muted-foreground py-4">Nenhuma data relevante neste mês.</p>
)}
</CardContent>
</Card>
{(client.website_url || client.instagram_url || client.facebook_url || client.linkedin_url) && (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-base">Links do cliente</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-2 space-y-2">
<ClientLink label="Website" url={client.website_url} />
<ClientLink label="Instagram" url={client.instagram_url} />
<ClientLink label="Facebook" url={client.facebook_url} />
<ClientLink label="LinkedIn" url={client.linkedin_url} />
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
function MetricCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
return (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
</CardContent>
</Card>
);
}
function ClientLink({ label, url }: { label: string; url: string }) {
if (!url) return null;
return (
<a className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30" href={url} target="_blank" rel="noreferrer">
<span>{label}</span>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</a>
);
}
function formatDateValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}

View File

@@ -0,0 +1,345 @@
import { Archive, Building2, ChevronDown, ChevronUp, Plus, Save, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { archiveClient, createClient, deleteClient, updateClient, type Client } from "@/lib/clients";
import { getStoredToken } from "@/lib/auth";
type ClientsViewProps = {
clients: Client[];
onClientCreated: (client: Client) => void;
onClientUpdated: (client: Client) => void;
onClientDeleted: (clientId: string) => void;
onClientSelect: (clientId: string) => void;
};
export function ClientsView({ clients, onClientCreated, onClientUpdated, onClientDeleted, onClientSelect }: ClientsViewProps) {
const [name, setName] = useState("");
const [editing, setEditing] = useState<Record<string, Client>>({});
const [expandedClients, setExpandedClients] = useState<Record<string, boolean>>({});
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [busyClientId, setBusyClientId] = useState("");
const [error, setError] = useState("");
const [deleteError, setDeleteError] = useState("");
const colorOptions = ["#f97316", "#06b6d4", "#22c55e", "#a855f7", "#ef4444", "#eab308", "#14b8a6", "#64748b"];
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
if (!token) return;
setError("");
setIsSubmitting(true);
try {
const client = await createClient(token, name);
onClientCreated(client);
setName("");
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel criar o cliente.");
} finally {
setIsSubmitting(false);
}
}
function draftFor(client: Client) {
return editing[client.id] ?? client;
}
function updateDraft(client: Client, field: keyof Client, value: string) {
setEditing((current) => ({
...current,
[client.id]: { ...draftFor(client), [field]: value },
}));
}
async function handleSave(client: Client) {
const token = getStoredToken();
if (!token) return;
setBusyClientId(client.id);
setError("");
try {
const updated = await updateClient(token, draftFor(client));
onClientUpdated(updated);
setEditing((current) => {
const copy = { ...current };
delete copy[client.id];
return copy;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel atualizar o cliente.");
} finally {
setBusyClientId("");
}
}
async function handleArchive(client: Client) {
const token = getStoredToken();
if (!token) return;
setBusyClientId(client.id);
setError("");
try {
const archived = await archiveClient(token, draftFor(client));
onClientUpdated(archived);
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel arquivar o cliente.");
} finally {
setBusyClientId("");
}
}
async function handleDeleteClient() {
const token = getStoredToken();
if (!token || !clientToDelete || deleteConfirmation !== clientToDelete.name) return;
setBusyClientId(clientToDelete.id);
setDeleteError("");
try {
await deleteClient(token, clientToDelete.id);
onClientDeleted(clientToDelete.id);
setClientToDelete(null);
setDeleteConfirmation("");
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Nao foi possivel excluir o cliente.");
} finally {
setBusyClientId("");
}
}
function viewAsClient(client: Client) {
onClientSelect(client.id);
setExpandedClients((current) => ({ ...current, [client.id]: true }));
}
function toggleClient(client: Client) {
onClientSelect(client.id);
setExpandedClients((current) => ({ ...current, [client.id]: !current[client.id] }));
}
return (
<>
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Clientes</h1>
<p className="text-sm text-muted-foreground mt-1">Cadastre e organize os clientes atendidos pela agência.</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Novo cliente</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col sm:flex-row gap-3" onSubmit={handleCreate}>
<div className="grid gap-2 flex-1">
<Label htmlFor="client-name">Nome do cliente</Label>
<Input id="client-name" placeholder="Ex.: TechCorp" value={name} onChange={(event) => setName(event.target.value)} required />
</div>
<Button className="sm:self-end" type="submit" disabled={isSubmitting}>
<Plus className="mr-2 h-4 w-4" />
{isSubmitting ? "Criando..." : "Criar cliente"}
</Button>
</form>
{error && (
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-3">
{clients.length === 0 && (
<div className="flex items-center justify-center h-[260px] border border-dashed border-border rounded-xl">
<div className="text-center space-y-3">
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
<Building2 className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">Nenhum cliente cadastrado</h3>
<p className="text-sm text-muted-foreground w-[300px]">Crie o primeiro cliente para começar a montar calendários e posts.</p>
</div>
</div>
)}
{clients.map((client) => {
const draft = draftFor(client);
const isArchived = client.status === "archived";
const isExpanded = expandedClients[client.id] ?? false;
return (
<Card key={client.id} className={isArchived ? "opacity-70" : ""}>
<CardContent className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: client.color || "#f97316" }} />
<h3 className="font-medium truncate">{client.name}</h3>
</div>
<p className="text-sm text-muted-foreground truncate">/{client.slug}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge variant={isArchived ? "outline" : "secondary"}>{isArchived ? "Arquivado" : "Ativo"}</Badge>
<Button type="button" size="sm" variant="outline" onClick={() => toggleClient(client)}>
{isExpanded ? <ChevronUp className="mr-2 h-4 w-4" /> : <ChevronDown className="mr-2 h-4 w-4" />}
{isExpanded ? "Minimizar" : "Detalhes"}
</Button>
</div>
</div>
{isExpanded && (
<>
<div className="grid gap-3 md:grid-cols-2">
<Field label="Nome" value={draft.name} onChange={(value) => updateDraft(client, "name", value)} />
<Field label="Website" value={draft.website_url} onChange={(value) => updateDraft(client, "website_url", value)} />
<Field label="Instagram" value={draft.instagram_url} onChange={(value) => updateDraft(client, "instagram_url", value)} />
<Field label="Facebook" value={draft.facebook_url} onChange={(value) => updateDraft(client, "facebook_url", value)} />
<Field label="LinkedIn" value={draft.linkedin_url} onChange={(value) => updateDraft(client, "linkedin_url", value)} />
</div>
<div className="grid gap-2">
<Label>Cor do cliente</Label>
<div className="flex flex-wrap items-center gap-2">
{colorOptions.map((color) => (
<button
key={color}
type="button"
aria-label={`Selecionar cor ${color}`}
className={`h-8 w-8 rounded-full border-2 transition ${draft.color === color ? "border-foreground" : "border-transparent"}`}
style={{ backgroundColor: color }}
onClick={() => updateDraft(client, "color", color)}
/>
))}
<Input
className="h-9 w-[120px] font-mono"
value={draft.color}
onChange={(event) => updateDraft(client, "color", event.target.value)}
pattern="^#[0-9a-fA-F]{6}$"
/>
</div>
</div>
<div className="grid gap-2">
<Label>Notas</Label>
<Textarea value={draft.notes} onChange={(event) => updateDraft(client, "notes", event.target.value)} />
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => handleSave(client)} disabled={busyClientId === client.id}>
<Save className="mr-2 h-4 w-4" />
Salvar
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => viewAsClient(client)}>
Atualizar dashboard
</Button>
{!isArchived && (
<Button type="button" size="sm" variant="outline" onClick={() => handleArchive(client)} disabled={busyClientId === client.id}>
<Archive className="mr-2 h-4 w-4" />
Arquivar
</Button>
)}
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => {
setClientToDelete(client);
setDeleteConfirmation("");
setDeleteError("");
}}
disabled={busyClientId === client.id}
>
<Trash2 className="mr-2 h-4 w-4" />
Excluir
</Button>
</div>
</>
)}
</CardContent>
</Card>
);
})}
</div>
</div>
<Dialog open={clientToDelete !== null} onOpenChange={(open) => {
if (open) return;
setClientToDelete(null);
setDeleteConfirmation("");
setDeleteError("");
}}>
<DialogContent className="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>Excluir cliente</DialogTitle>
<DialogDescription>
Esta ação exclui permanentemente o cliente, seus vínculos, posts e datas personalizadas. Para confirmar, digite o nome do cliente.
</DialogDescription>
</DialogHeader>
{clientToDelete && (
<div className="space-y-4 pt-2">
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground">Nome do cliente: </span>
<span className="font-medium">{clientToDelete.name}</span>
</div>
<div className="grid gap-2">
<Label htmlFor="delete-client-confirmation">Digite o nome do cliente</Label>
<Input
id="delete-client-confirmation"
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.target.value)}
autoComplete="off"
/>
</div>
{deleteError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{deleteError}
</div>
)}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setClientToDelete(null);
setDeleteConfirmation("");
setDeleteError("");
}}
>
Cancelar
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteConfirmation !== clientToDelete.name || busyClientId === clientToDelete.id}
onClick={handleDeleteClient}
>
{busyClientId === clientToDelete.id ? "Excluindo..." : "Excluir definitivamente"}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
<Input value={value} onChange={(event) => onChange(event.target.value)} />
</div>
);
}

View File

@@ -0,0 +1,344 @@
import { useEffect, useMemo, useState } from "react";
import { AlertCircle, Calendar as CalendarIcon, CheckCircle2, Clock, FileImage, Filter, Plus } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { getStoredToken } from "@/lib/auth";
import {
listCalendarItemsByRange,
listCustomDates,
listHolidays,
type CalendarItem,
type CustomDate,
type Holiday,
} from "@/lib/calendar";
type DashboardViewProps = {
selectedClientId?: string | null;
title?: string;
description?: string;
compact?: boolean;
onViewChange: (view: string) => void;
onPostSelect: (postId: string) => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
export function DashboardView({
selectedClientId = null,
title = "Dashboard Semanal",
description,
compact = false,
onViewChange,
onPostSelect,
}: DashboardViewProps) {
const today = useMemo(() => new Date(), []);
const weekStart = useMemo(() => startOfWeek(today), [today]);
const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]);
const [items, setItems] = useState<CalendarItem[]>([]);
const [holidays, setHolidays] = useState<Holiday[]>([]);
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [showFilters, setShowFilters] = useState(false);
const [statusFilter, setStatusFilter] = useState<"all" | CalendarItem["status"]>("all");
const [searchFilter, setSearchFilter] = useState("");
const todayValue = formatDateValue(today);
const weekStartValue = formatDateValue(weekStart);
const weekEndValue = formatDateValue(weekEnd);
const years = useMemo(() => yearsInRange(weekStart, weekEnd), [weekEnd, weekStart]);
const yearsKey = years.join(",");
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([
listCalendarItemsByRange(token, weekStartValue, weekEndValue, selectedClientId),
Promise.all(years.map((year) => listHolidays(token, year))),
Promise.all(years.map((year) => listCustomDates(token, year, selectedClientId))),
])
.then(([loadedItems, loadedHolidaysByYear, loadedCustomDatesByYear]) => {
setItems(loadedItems);
setHolidays(loadedHolidaysByYear.flat());
setCustomDates(loadedCustomDatesByYear.flat());
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard.");
})
.finally(() => setIsLoading(false));
}, [selectedClientId, weekEndValue, weekStartValue, yearsKey]);
const filteredItems = items.filter((item) => {
const matchesStatus = statusFilter === "all" || item.status === statusFilter;
const term = searchFilter.trim().toLowerCase();
const matchesSearch =
term === "" ||
item.title.toLowerCase().includes(term) ||
item.client_name.toLowerCase().includes(term);
return matchesStatus && matchesSearch;
});
const todayItems = filteredItems.filter((item) => item.scheduled_date === todayValue);
const reviewItems = filteredItems.filter((item) => item.status === "em_revisao");
const approvedItems = filteredItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
const pendingItems = filteredItems.filter((item) => !["aprovado", "publicado", "cancelado"].includes(item.status));
const weekDates = [...holidays, ...customDates]
.filter((date) => date.date >= weekStartValue && date.date <= weekEndValue)
.sort((a, b) => a.date.localeCompare(b.date));
const warningDate = weekDates[0];
const periodDescription =
description ??
`Visão geral entre ${shortDateFormatter.format(weekStart)} e ${shortDateFormatter.format(weekEnd)}.`;
return (
<div className={compact ? "space-y-4" : "space-y-6"}>
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
<div>
<h1 className={compact ? "text-lg font-semibold tracking-tight" : "text-2xl font-semibold tracking-tight"}>{title}</h1>
<p className="text-sm text-muted-foreground mt-1">
{periodDescription}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={() => setShowFilters((current) => !current)}>
<Filter className="mr-2 h-4 w-4" />
Filtros
</Button>
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
<Plus className="mr-2 h-4 w-4" />
Novo Post
</Button>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{showFilters && (
<div className="grid gap-3 rounded-lg border border-border/60 bg-card p-4 sm:grid-cols-[180px_1fr]">
<div className="grid gap-2">
<label className="text-sm font-medium">Status</label>
<select
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as "all" | CalendarItem["status"])}
>
<option value="all">Todos</option>
{Object.entries(statusLabels).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Buscar</label>
<input
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
placeholder="Título ou cliente"
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}
/>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<SummaryCard title="Para Hoje" value={todayItems.length} detail={`${pendingItems.length} pendentes na semana`} />
<SummaryCard title="Em Aprovação" value={reviewItems.length} detail="Aguardando revisão ou cliente" />
<SummaryCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
<Card className="shadow-sm bg-muted/40 border-dashed border-2">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Avisos</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-sm font-medium text-foreground flex items-center gap-1.5">
<AlertCircle className="h-4 w-4 text-orange-500" />
{warningDate ? warningDate.name : "Sem datas críticas"}
</div>
<p className="text-xs text-muted-foreground mt-1">
{warningDate ? shortDateFormatter.format(parseDateValue(warningDate.date)) : "Nenhum feriado ou data na semana"}
</p>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Tabs defaultValue="pending" className="w-full">
<div className="flex items-center justify-between">
<TabsList className="bg-transparent border-b border-border/40 w-full justify-start h-auto p-0 rounded-none">
<TabsTrigger value="pending" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
Ações Pendentes
<Badge variant="secondary" className="ml-2 h-5 px-1.5 rounded-sm">{pendingItems.length}</Badge>
</TabsTrigger>
<TabsTrigger value="approved" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
Aprovados
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="pending" className="pt-4 space-y-3 m-0">
<PostList items={pendingItems} isLoading={isLoading} emptyText="Nenhuma ação pendente nesta semana." onPostSelect={onPostSelect} />
</TabsContent>
<TabsContent value="approved" className="pt-4 space-y-3 m-0">
<PostList items={approvedItems} isLoading={isLoading} emptyText="Nenhum post aprovado nesta semana." onPostSelect={onPostSelect} />
</TabsContent>
</Tabs>
</div>
<div className="space-y-6">
<Card className="shadow-sm">
<CardHeader className="p-4 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
Datas da Semana
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-3 space-y-3">
{weekDates.map((date) => (
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between text-sm">
<div className="flex flex-col">
<span className="font-medium text-foreground">{date.name}</span>
<span className="text-xs text-muted-foreground">{"source" in date ? "Nacional" : "Personalizada"}</span>
</div>
<Badge variant="outline" className="font-mono bg-muted/40">
{shortDateFormatter.format(parseDateValue(date.date))}
</Badge>
</div>
))}
{weekDates.length === 0 && (
<p className="text-sm text-muted-foreground py-3">Nenhuma data comemorativa nesta semana.</p>
)}
</CardContent>
</Card>
<Button variant="ghost" className="w-full text-xs h-8 text-primary" onClick={() => onViewChange("calendar")}>
Ver calendário completo
</Button>
</div>
</div>
</div>
);
}
function SummaryCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
return (
<Card className="shadow-sm">
<CardHeader className="p-4 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
</CardContent>
</Card>
);
}
function PostList({
items,
isLoading,
emptyText,
onPostSelect,
}: {
items: CalendarItem[];
isLoading: boolean;
emptyText: string;
onPostSelect: (postId: string) => void;
}) {
if (isLoading) {
return <p className="text-sm text-muted-foreground py-6">Carregando postagens...</p>;
}
if (items.length === 0) {
return <p className="text-sm text-muted-foreground py-6">{emptyText}</p>;
}
return (
<>
{items.map((post) => (
<button key={post.id} type="button" className="flex w-full flex-col sm:flex-row gap-4 p-4 rounded-lg border border-border/60 bg-card hover:bg-muted/30 transition-colors shadow-sm text-left" onClick={() => onPostSelect(post.id)}>
<div className="flex-1 space-y-1 z-10">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-[10px] uppercase font-semibold text-muted-foreground">{post.client_name}</Badge>
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20">
{post.content_type}
</Badge>
</div>
<h3 className="font-medium text-sm text-foreground line-clamp-1">{post.title}</h3>
<div className="flex items-center gap-4 text-xs text-muted-foreground pt-1">
<span className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" /> {shortDateFormatter.format(parseDateValue(post.scheduled_date))}</span>
<span className="flex items-center gap-1.5"><FileImage className="h-3.5 w-3.5" /> Anexos pendentes</span>
</div>
</div>
<div className="flex sm:flex-col items-center sm:items-end justify-between sm:justify-center border-t sm:border-t-0 sm:border-l border-border/40 pt-3 sm:pt-0 sm:pl-4 mt-2 sm:mt-0 gap-2">
<span className="text-xs font-medium text-orange-600 dark:text-orange-300 flex items-center gap-1.5">
{post.status === "aprovado" || post.status === "publicado" ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertCircle className="h-3.5 w-3.5" />}
{statusLabels[post.status]}
</span>
<Avatar className="h-6 w-6 border-border border">
<AvatarFallback className="text-[10px] bg-primary/5 text-primary font-medium">{initials(post.client_name)}</AvatarFallback>
</Avatar>
</div>
</button>
))}
</>
);
}
function startOfWeek(date: Date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay();
const mondayOffset = day === 0 ? -6 : 1 - day;
return addDays(copy, mondayOffset);
}
function addDays(date: Date, days: number) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function formatDateValue(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function yearsInRange(from: Date, to: Date) {
const years: number[] = [];
for (let year = from.getFullYear(); year <= to.getFullYear(); year += 1) {
years.push(year);
}
return years;
}
function initials(value: string) {
return value
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}

View File

@@ -0,0 +1,118 @@
import { useState, type FormEvent } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { ModeToggle } from "@/components/mode-toggle";
import { Eye, EyeOff } from "lucide-react";
import { login, type AuthUser } from "@/lib/auth";
export function LoginView({ onLogin }: { onLogin: (user: AuthUser) => void }) {
const [showPassword, setShowPassword] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setIsSubmitting(true);
try {
const response = await login(email, password);
onLogin(response.user);
} catch (err) {
setError(err instanceof Error ? err.message : "Nao foi possivel fazer login.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-muted/40 flex items-center justify-center p-4 relative">
<div className="absolute top-4 right-4">
<ModeToggle />
</div>
<div className="w-full max-w-md space-y-6">
<div className="flex justify-center flex-col items-center gap-2 mb-8">
<div className="h-12 w-12 bg-primary rounded-xl flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl tracking-tighter">M</span>
</div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Mira</h1>
<p className="text-sm text-muted-foreground">Planejamento Editorial & Operações</p>
</div>
<Card className="shadow-sm border-border/50">
<CardHeader className="space-y-1 pb-4">
<CardTitle className="text-xl">Acessar a Plataforma</CardTitle>
<CardDescription>
Insira suas credenciais corporativas ou do cliente.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-mail corporativo</Label>
<Input
id="email"
type="email"
placeholder="nome@agencia.com.br"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="space-y-2 relative">
<div className="flex items-center justify-between">
<Label htmlFor="password">Senha</Label>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Sua senha de acesso"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<Button
variant="ghost"
size="icon"
type="button"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
<div className="flex items-center space-x-2 pt-2">
<Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal text-muted-foreground">Lembrar neste dispositivo</Label>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
<CardFooter>
<Button className="w-full" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Entrando..." : "Entrar"}
</Button>
</CardFooter>
</form>
</Card>
<div className="text-center text-xs text-muted-foreground">
Problemas com o acesso? Fale com o gestor de contas.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { getStoredToken } from "@/lib/auth";
import {
attachmentDownloadURL,
getCalendarItem,
listAttachments,
uploadAttachment,
type Attachment,
type CalendarItem,
} from "@/lib/calendar";
type PostDetailViewProps = {
itemId: string;
onBack: () => void;
};
const statusLabels: Record<CalendarItem["status"], string> = {
rascunho: "Rascunho",
planejado: "Planejado",
em_producao: "Em produção",
em_revisao: "Em revisão",
aprovado: "Aprovado",
publicado: "Publicado",
cancelado: "Cancelado",
};
const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
export function PostDetailView({ itemId, onBack }: PostDetailViewProps) {
const [item, setItem] = useState<CalendarItem | null>(null);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState("");
const [attachmentError, setAttachmentError] = useState("");
const imageAttachments = useMemo(() => attachments.filter((attachment) => isImageAttachment(attachment)), [attachments]);
const fileAttachments = useMemo(() => attachments.filter((attachment) => !isImageAttachment(attachment)), [attachments]);
useEffect(() => {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
Promise.all([getCalendarItem(token, itemId), listAttachments(token, itemId)])
.then(([loadedItem, loadedAttachments]) => {
setItem(loadedItem);
setAttachments(loadedAttachments);
})
.catch((err) => {
setError(err instanceof Error ? err.message : "Não foi possível carregar a postagem.");
})
.finally(() => setIsLoading(false));
}, [itemId]);
async function handleUploadAttachment(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
const fileInput = event.currentTarget.elements.namedItem("attachment") as HTMLInputElement | null;
const file = fileInput?.files?.[0];
if (!token || !file) return;
setIsUploading(true);
setAttachmentError("");
try {
const attachment = await uploadAttachment(token, itemId, file);
setAttachments((current) => [attachment, ...current]);
if (fileInput) fileInput.value = "";
} catch (err) {
setAttachmentError(err instanceof Error ? err.message : "Não foi possível enviar o anexo.");
} finally {
setIsUploading(false);
}
}
if (isLoading && !item) {
return <p className="text-sm text-muted-foreground">Carregando postagem...</p>;
}
if (error) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
</div>
);
}
if (!item) return null;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-3">
<Button variant="outline" size="sm" onClick={onBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{item.title}</h1>
<Badge variant="secondary" className="bg-orange-500/10 text-orange-700 dark:text-orange-300">
{statusLabels[item.status]}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{item.client_name} · {dateFormatter.format(parseDateValue(item.scheduled_date))}
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Detalhes</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ReadOnlyField label="Resumo" value={item.description} fallback="Sem resumo." />
<ReadOnlyField label="Texto da postagem" value={item.copy_text} fallback="Sem texto cadastrado." multiline />
<ReadOnlyField label="Notas para o cliente" value={item.client_notes} fallback="Sem notas para o cliente." multiline />
<ReadOnlyField label="Notas internas" value={item.internal_notes} fallback="Sem notas internas." multiline />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Anexos</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<form className="grid gap-3 sm:grid-cols-[1fr_auto]" onSubmit={handleUploadAttachment}>
<div className="grid gap-2">
<Label htmlFor="post-detail-attachment">Enviar anexo</Label>
<Input id="post-detail-attachment" name="attachment" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" />
</div>
<Button className="sm:self-end" type="submit" disabled={isUploading}>
<Upload className="mr-2 h-4 w-4" />
{isUploading ? "Enviando..." : "Enviar"}
</Button>
</form>
{attachmentError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{attachmentError}
</div>
)}
{attachments.length === 0 && (
<p className="text-sm text-muted-foreground py-4">Nenhum anexo enviado.</p>
)}
{imageAttachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{imageAttachments.map((attachment) => (
<button
key={attachment.id}
type="button"
className="group overflow-hidden rounded-lg border border-border/60 bg-muted/20 text-left"
onClick={() => setPreviewAttachment(attachment)}
>
<img
src={attachmentDownloadURL(attachment.id)}
alt={attachment.original_filename}
className="aspect-square w-full object-cover transition group-hover:scale-[1.02]"
/>
<div className="flex items-center gap-2 px-2 py-2 text-xs">
<ImageIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{attachment.original_filename}</span>
</div>
</button>
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="space-y-2">
{fileAttachments.map((attachment) => (
<a
key={attachment.id}
href={attachmentDownloadURL(attachment.id)}
target="_blank"
rel="noreferrer"
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30"
>
<span className="flex min-w-0 items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{attachment.original_filename}</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{formatBytes(attachment.size_bytes)}
<Download className="h-4 w-4" />
</span>
</a>
))}
</div>
)}
</CardContent>
</Card>
</div>
<Card className="h-fit">
<CardHeader>
<CardTitle className="text-base">Informações</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<InfoRow label="Cliente" value={item.client_name} />
<InfoRow label="Status" value={statusLabels[item.status]} />
<InfoRow label="Tipo" value={item.content_type} />
<InfoRow label="Data" value={dateFormatter.format(parseDateValue(item.scheduled_date))} />
<InfoRow label="Anexos" value={String(attachments.length)} />
</CardContent>
</Card>
</div>
<Dialog open={previewAttachment !== null} onOpenChange={(open) => !open && setPreviewAttachment(null)}>
<DialogContent className="sm:max-w-[860px]">
<DialogHeader>
<DialogTitle>{previewAttachment?.original_filename}</DialogTitle>
<DialogDescription>Visualização do anexo.</DialogDescription>
</DialogHeader>
{previewAttachment && (
<div className="overflow-hidden rounded-lg border border-border/60 bg-black/5">
<img
src={attachmentDownloadURL(previewAttachment.id)}
alt={previewAttachment.original_filename}
className="max-h-[70vh] w-full object-contain"
/>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}
function ReadOnlyField({ label, value, fallback, multiline = false }: { label: string; value: string; fallback: string; multiline?: boolean }) {
return (
<div className="grid gap-2">
<Label>{label}</Label>
{multiline ? (
<Textarea value={value || fallback} readOnly className="min-h-[110px]" />
) : (
<Input value={value || fallback} readOnly />
)}
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 border-b border-border/40 pb-2 last:border-b-0 last:pb-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
);
}
function isImageAttachment(attachment: Attachment) {
return attachment.mime_type.startsWith("image/");
}
function parseDateValue(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(year, month - 1, day);
}
function formatBytes(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
return `${(value / 1024 / 1024).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,226 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { Copy, Plus, Users } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getStoredToken, type AuthUser } from "@/lib/auth";
import { createInvitation, listInvitations, listUsers, type Invitation } from "@/lib/users";
import type { Client } from "@/lib/clients";
type UsersViewProps = {
clients: Client[];
};
const roleLabels: Record<AuthUser["role"] | Invitation["role"], string> = {
super_admin: "Super-admin",
agency_user: "Agência",
client_viewer: "Cliente",
};
export function UsersView({ clients }: UsersViewProps) {
const [users, setUsers] = useState<AuthUser[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [email, setEmail] = useState("");
const [role, setRole] = useState<Invitation["role"]>("agency_user");
const [clientId, setClientId] = useState("");
const [createdURL, setCreatedURL] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
void refresh();
}, []);
async function refresh() {
const token = getStoredToken();
if (!token) return;
setIsLoading(true);
setError("");
try {
const [loadedUsers, loadedInvitations] = await Promise.all([listUsers(token), listInvitations(token)]);
setUsers(loadedUsers);
setInvitations(loadedInvitations);
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível carregar acessos.");
} finally {
setIsLoading(false);
}
}
async function handleInvite(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const token = getStoredToken();
if (!token) return;
setIsSubmitting(true);
setError("");
setCreatedURL("");
try {
const invitation = await createInvitation(token, {
email,
role,
client_id: role === "client_viewer" ? clientId : undefined,
});
setInvitations((current) => [invitation, ...current]);
setEmail("");
setClientId("");
setCreatedURL(invitation.invitation_url ?? "");
} catch (err) {
setError(err instanceof Error ? err.message : "Não foi possível criar o convite.");
} finally {
setIsSubmitting(false);
}
}
async function copyInvitationURL() {
if (!createdURL) return;
await navigator.clipboard.writeText(createdURL);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Gestão de Acessos</h1>
<p className="text-sm text-muted-foreground mt-1">Gerencie usuários da agência e clientes.</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Convidar usuário</CardTitle>
</CardHeader>
<CardContent>
<form className="grid gap-3 md:grid-cols-[1fr_180px_220px_auto]" onSubmit={handleInvite}>
<div className="grid gap-2">
<Label htmlFor="invite-email">E-mail</Label>
<Input
id="invite-email"
type="email"
placeholder="nome@empresa.com.br"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-role">Perfil</Label>
<select
id="invite-role"
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
value={role}
onChange={(event) => setRole(event.target.value as Invitation["role"])}
>
<option value="agency_user">Agência</option>
<option value="client_viewer">Cliente</option>
</select>
</div>
<div className="grid gap-2">
<Label htmlFor="invite-client">Cliente</Label>
<select
id="invite-client"
className="h-9 rounded-lg border border-input bg-background px-3 text-sm disabled:opacity-60"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
disabled={role !== "client_viewer"}
required={role === "client_viewer"}
>
<option value="">Selecione</option>
{clients.map((client) => (
<option key={client.id} value={client.id}>{client.name}</option>
))}
</select>
</div>
<Button className="md:self-end" type="submit" disabled={isSubmitting || (role === "client_viewer" && !clientId)}>
<Plus className="mr-2 h-4 w-4" />
{isSubmitting ? "Criando..." : "Convidar"}
</Button>
</form>
{createdURL && (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
<div className="text-xs font-medium text-muted-foreground mb-2">Link do convite</div>
<div className="flex flex-col sm:flex-row gap-2">
<Input value={createdURL} readOnly />
<Button type="button" variant="outline" onClick={copyInvitationURL}>
<Copy className="mr-2 h-4 w-4" />
Copiar
</Button>
</div>
</div>
)}
{error && (
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<AccessList title="Usuários ativos" emptyText="Nenhum usuário carregado." isLoading={isLoading}>
{users.map((user) => (
<div key={user.id} className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-card p-4">
<div className="min-w-0">
<h3 className="font-medium truncate">{user.name}</h3>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
<Badge variant="secondary">{roleLabels[user.role]}</Badge>
</div>
))}
</AccessList>
<AccessList title="Convites" emptyText="Nenhum convite criado." isLoading={isLoading}>
{invitations.map((invitation) => (
<div key={invitation.id} className="rounded-lg border border-border/60 bg-card p-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="font-medium truncate">{invitation.email}</h3>
<p className="text-sm text-muted-foreground truncate">
{invitation.client_name ? `Cliente: ${invitation.client_name}` : "Acesso da agência"}
</p>
</div>
<Badge variant={invitation.accepted_at ? "secondary" : "outline"}>
{invitation.accepted_at ? "Aceito" : roleLabels[invitation.role]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Expira em {new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium" }).format(new Date(invitation.expires_at))}
</p>
</div>
))}
</AccessList>
</div>
</div>
);
}
function AccessList({ title, emptyText, isLoading, children }: { title: string; emptyText: string; isLoading: boolean; children: ReactNode }) {
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
return (
<div className="space-y-3">
<h2 className="text-base font-semibold">{title}</h2>
{isLoading && <p className="text-sm text-muted-foreground py-4">Carregando...</p>}
{!isLoading && !hasChildren && (
<div className="flex items-center justify-center h-[220px] border border-dashed border-border rounded-xl">
<div className="text-center space-y-3">
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
<Users className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground w-[260px]">{emptyText}</p>
</div>
</div>
)}
{!isLoading && hasChildren && <div className="grid gap-3">{children}</div>}
</div>
);
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
return {
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
// HMR is disabled in AI Studio via DISABLE_HMR env var.
// Do not modify—file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
};
});