Initial copy from influencer-biz
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
---
|
||||
name: langchain
|
||||
description: Framework for building LLM-powered applications with agents, chains, and RAG. Supports multiple providers (OpenAI, Anthropic, Google), 500+ integrations, ReAct agents, tool calling, memory management, and vector store retrieval. Use for building chatbots, question-answering systems, autonomous agents, or RAG applications. Best for rapid prototyping and production deployments.
|
||||
version: 1.0.0
|
||||
author: Orchestra Research
|
||||
license: MIT
|
||||
tags: [Agents, LangChain, RAG, Tool Calling, ReAct, Memory Management, Vector Stores, LLM Applications, Chatbots, Production]
|
||||
dependencies: [langchain, langchain-core, langchain-openai, langchain-anthropic]
|
||||
---
|
||||
|
||||
# LangChain - Build LLM Applications with Agents & RAG
|
||||
|
||||
The most popular framework for building LLM-powered applications.
|
||||
|
||||
## When to use LangChain
|
||||
|
||||
**Use LangChain when:**
|
||||
- Building agents with tool calling and reasoning (ReAct pattern)
|
||||
- Implementing RAG (retrieval-augmented generation) pipelines
|
||||
- Need to swap LLM providers easily (OpenAI, Anthropic, Google)
|
||||
- Creating chatbots with conversation memory
|
||||
- Rapid prototyping of LLM applications
|
||||
- Production deployments with LangSmith observability
|
||||
|
||||
**Metrics**:
|
||||
- **119,000+ GitHub stars**
|
||||
- **272,000+ repositories** use LangChain
|
||||
- **500+ integrations** (models, vector stores, tools)
|
||||
- **3,800+ contributors**
|
||||
|
||||
**Use alternatives instead**:
|
||||
- **LlamaIndex**: RAG-focused, better for document Q&A
|
||||
- **LangGraph**: Complex stateful workflows, more control
|
||||
- **Haystack**: Production search pipelines
|
||||
- **Semantic Kernel**: Microsoft ecosystem
|
||||
|
||||
## Quick start
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Core library (Python 3.10+)
|
||||
pip install -U langchain
|
||||
|
||||
# With OpenAI
|
||||
pip install langchain-openai
|
||||
|
||||
# With Anthropic
|
||||
pip install langchain-anthropic
|
||||
|
||||
# Common extras
|
||||
pip install langchain-community # 500+ integrations
|
||||
pip install langchain-chroma # Vector store
|
||||
```
|
||||
|
||||
### Basic LLM usage
|
||||
|
||||
```python
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# Initialize model
|
||||
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
||||
|
||||
# Simple completion
|
||||
response = llm.invoke("Explain quantum computing in 2 sentences")
|
||||
print(response.content)
|
||||
```
|
||||
|
||||
### Create an agent (ReAct pattern)
|
||||
|
||||
```python
|
||||
from langchain.agents import create_agent
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# Define tools
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get current weather for a city."""
|
||||
return f"It's sunny in {city}, 72°F"
|
||||
|
||||
def search_web(query: str) -> str:
|
||||
"""Search the web for information."""
|
||||
return f"Search results for: {query}"
|
||||
|
||||
# Create agent (<10 lines!)
|
||||
agent = create_agent(
|
||||
model=ChatAnthropic(model="claude-sonnet-4-5-20250929"),
|
||||
tools=[get_weather, search_web],
|
||||
system_prompt="You are a helpful assistant. Use tools when needed."
|
||||
)
|
||||
|
||||
# Run agent
|
||||
result = agent.invoke({"messages": [{"role": "user", "content": "What's the weather in Paris?"}]})
|
||||
print(result["messages"][-1].content)
|
||||
```
|
||||
|
||||
## Core concepts
|
||||
|
||||
### 1. Models - LLM abstraction
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
# Swap providers easily
|
||||
llm = ChatOpenAI(model="gpt-4o")
|
||||
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
||||
llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash-exp")
|
||||
|
||||
# Streaming
|
||||
for chunk in llm.stream("Write a poem"):
|
||||
print(chunk.content, end="", flush=True)
|
||||
```
|
||||
|
||||
### 2. Chains - Sequential operations
|
||||
|
||||
```python
|
||||
from langchain.chains import LLMChain
|
||||
from langchain.prompts import PromptTemplate
|
||||
|
||||
# Define prompt template
|
||||
prompt = PromptTemplate(
|
||||
input_variables=["topic"],
|
||||
template="Write a 3-sentence summary about {topic}"
|
||||
)
|
||||
|
||||
# Create chain
|
||||
chain = LLMChain(llm=llm, prompt=prompt)
|
||||
|
||||
# Run chain
|
||||
result = chain.run(topic="machine learning")
|
||||
```
|
||||
|
||||
### 3. Agents - Tool-using reasoning
|
||||
|
||||
**ReAct (Reasoning + Acting) pattern:**
|
||||
|
||||
```python
|
||||
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
||||
from langchain.tools import Tool
|
||||
|
||||
# Define custom tool
|
||||
calculator = Tool(
|
||||
name="Calculator",
|
||||
func=lambda x: eval(x),
|
||||
description="Useful for math calculations. Input: valid Python expression."
|
||||
)
|
||||
|
||||
# Create agent with tools
|
||||
agent = create_tool_calling_agent(
|
||||
llm=llm,
|
||||
tools=[calculator, search_web],
|
||||
prompt="Answer questions using available tools"
|
||||
)
|
||||
|
||||
# Create executor
|
||||
agent_executor = AgentExecutor(agent=agent, tools=[calculator], verbose=True)
|
||||
|
||||
# Run with reasoning
|
||||
result = agent_executor.invoke({"input": "What is 25 * 17 + 142?"})
|
||||
```
|
||||
|
||||
### 4. Memory - Conversation history
|
||||
|
||||
```python
|
||||
from langchain.memory import ConversationBufferMemory
|
||||
from langchain.chains import ConversationChain
|
||||
|
||||
# Add memory to track conversation
|
||||
memory = ConversationBufferMemory()
|
||||
|
||||
conversation = ConversationChain(
|
||||
llm=llm,
|
||||
memory=memory,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Multi-turn conversation
|
||||
conversation.predict(input="Hi, I'm Alice")
|
||||
conversation.predict(input="What's my name?") # Remembers "Alice"
|
||||
```
|
||||
|
||||
## RAG (Retrieval-Augmented Generation)
|
||||
|
||||
### Basic RAG pipeline
|
||||
|
||||
```python
|
||||
from langchain_community.document_loaders import WebBaseLoader
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from langchain_chroma import Chroma
|
||||
from langchain.chains import RetrievalQA
|
||||
|
||||
# 1. Load documents
|
||||
loader = WebBaseLoader("https://docs.python.org/3/tutorial/")
|
||||
docs = loader.load()
|
||||
|
||||
# 2. Split into chunks
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=1000,
|
||||
chunk_overlap=200
|
||||
)
|
||||
splits = text_splitter.split_documents(docs)
|
||||
|
||||
# 3. Create embeddings and vector store
|
||||
vectorstore = Chroma.from_documents(
|
||||
documents=splits,
|
||||
embedding=OpenAIEmbeddings()
|
||||
)
|
||||
|
||||
# 4. Create retriever
|
||||
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
|
||||
|
||||
# 5. Create QA chain
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
return_source_documents=True
|
||||
)
|
||||
|
||||
# 6. Query
|
||||
result = qa_chain({"query": "What are Python decorators?"})
|
||||
print(result["result"])
|
||||
print(f"Sources: {result['source_documents']}")
|
||||
```
|
||||
|
||||
### Conversational RAG with memory
|
||||
|
||||
```python
|
||||
from langchain.chains import ConversationalRetrievalChain
|
||||
|
||||
# RAG with conversation memory
|
||||
qa = ConversationalRetrievalChain.from_llm(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
memory=ConversationBufferMemory(
|
||||
memory_key="chat_history",
|
||||
return_messages=True
|
||||
)
|
||||
)
|
||||
|
||||
# Multi-turn RAG
|
||||
qa({"question": "What is Python used for?"})
|
||||
qa({"question": "Can you elaborate on web development?"}) # Remembers context
|
||||
```
|
||||
|
||||
## Advanced agent patterns
|
||||
|
||||
### Structured output
|
||||
|
||||
```python
|
||||
from langchain_core.pydantic_v1 import BaseModel, Field
|
||||
|
||||
# Define schema
|
||||
class WeatherReport(BaseModel):
|
||||
city: str = Field(description="City name")
|
||||
temperature: float = Field(description="Temperature in Fahrenheit")
|
||||
condition: str = Field(description="Weather condition")
|
||||
|
||||
# Get structured response
|
||||
structured_llm = llm.with_structured_output(WeatherReport)
|
||||
result = structured_llm.invoke("What's the weather in SF? It's 65F and sunny")
|
||||
print(result.city, result.temperature, result.condition)
|
||||
```
|
||||
|
||||
### Parallel tool execution
|
||||
|
||||
```python
|
||||
from langchain.agents import create_tool_calling_agent
|
||||
|
||||
# Agent automatically parallelizes independent tool calls
|
||||
agent = create_tool_calling_agent(
|
||||
llm=llm,
|
||||
tools=[get_weather, search_web, calculator]
|
||||
)
|
||||
|
||||
# This will call get_weather("Paris") and get_weather("London") in parallel
|
||||
result = agent.invoke({
|
||||
"messages": [{"role": "user", "content": "Compare weather in Paris and London"}]
|
||||
})
|
||||
```
|
||||
|
||||
### Streaming agent execution
|
||||
|
||||
```python
|
||||
# Stream agent steps
|
||||
for step in agent_executor.stream({"input": "Research AI trends"}):
|
||||
if "actions" in step:
|
||||
print(f"Tool: {step['actions'][0].tool}")
|
||||
if "output" in step:
|
||||
print(f"Output: {step['output']}")
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Multi-document QA
|
||||
|
||||
```python
|
||||
from langchain.chains.qa_with_sources import load_qa_with_sources_chain
|
||||
|
||||
# Load multiple documents
|
||||
docs = [
|
||||
loader.load("https://docs.python.org"),
|
||||
loader.load("https://docs.numpy.org")
|
||||
]
|
||||
|
||||
# QA with source citations
|
||||
chain = load_qa_with_sources_chain(llm, chain_type="stuff")
|
||||
result = chain({"input_documents": docs, "question": "How to use numpy arrays?"})
|
||||
print(result["output_text"]) # Includes source citations
|
||||
```
|
||||
|
||||
### Custom tools with error handling
|
||||
|
||||
```python
|
||||
from langchain.tools import tool
|
||||
|
||||
@tool
|
||||
def risky_operation(query: str) -> str:
|
||||
"""Perform a risky operation that might fail."""
|
||||
try:
|
||||
# Your operation here
|
||||
result = perform_operation(query)
|
||||
return f"Success: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
# Agent handles errors gracefully
|
||||
agent = create_agent(model=llm, tools=[risky_operation])
|
||||
```
|
||||
|
||||
### LangSmith observability
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
# Enable tracing
|
||||
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
||||
os.environ["LANGCHAIN_API_KEY"] = "your-api-key"
|
||||
os.environ["LANGCHAIN_PROJECT"] = "my-project"
|
||||
|
||||
# All chains/agents automatically traced
|
||||
agent = create_agent(model=llm, tools=[calculator])
|
||||
result = agent.invoke({"input": "Calculate 123 * 456"})
|
||||
|
||||
# View traces at smith.langchain.com
|
||||
```
|
||||
|
||||
## Vector stores
|
||||
|
||||
### Chroma (local)
|
||||
|
||||
```python
|
||||
from langchain_chroma import Chroma
|
||||
|
||||
vectorstore = Chroma.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
persist_directory="./chroma_db"
|
||||
)
|
||||
```
|
||||
|
||||
### Pinecone (cloud)
|
||||
|
||||
```python
|
||||
from langchain_pinecone import PineconeVectorStore
|
||||
|
||||
vectorstore = PineconeVectorStore.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
index_name="my-index"
|
||||
)
|
||||
```
|
||||
|
||||
### FAISS (similarity search)
|
||||
|
||||
```python
|
||||
from langchain_community.vectorstores import FAISS
|
||||
|
||||
vectorstore = FAISS.from_documents(docs, OpenAIEmbeddings())
|
||||
vectorstore.save_local("faiss_index")
|
||||
|
||||
# Load later
|
||||
vectorstore = FAISS.load_local("faiss_index", OpenAIEmbeddings())
|
||||
```
|
||||
|
||||
## Document loaders
|
||||
|
||||
```python
|
||||
# Web pages
|
||||
from langchain_community.document_loaders import WebBaseLoader
|
||||
loader = WebBaseLoader("https://example.com")
|
||||
|
||||
# PDFs
|
||||
from langchain_community.document_loaders import PyPDFLoader
|
||||
loader = PyPDFLoader("paper.pdf")
|
||||
|
||||
# GitHub
|
||||
from langchain_community.document_loaders import GithubFileLoader
|
||||
loader = GithubFileLoader(repo="user/repo", file_filter=lambda x: x.endswith(".py"))
|
||||
|
||||
# CSV
|
||||
from langchain_community.document_loaders import CSVLoader
|
||||
loader = CSVLoader("data.csv")
|
||||
```
|
||||
|
||||
## Text splitters
|
||||
|
||||
```python
|
||||
# Recursive (recommended for general text)
|
||||
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
||||
splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=1000,
|
||||
chunk_overlap=200,
|
||||
separators=["\n\n", "\n", " ", ""]
|
||||
)
|
||||
|
||||
# Code-aware
|
||||
from langchain.text_splitter import PythonCodeTextSplitter
|
||||
splitter = PythonCodeTextSplitter(chunk_size=500)
|
||||
|
||||
# Semantic (by meaning)
|
||||
from langchain_experimental.text_splitter import SemanticChunker
|
||||
splitter = SemanticChunker(OpenAIEmbeddings())
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Start simple** - Use `create_agent()` for most cases
|
||||
2. **Enable streaming** - Better UX for long responses
|
||||
3. **Add error handling** - Tools can fail, handle gracefully
|
||||
4. **Use LangSmith** - Essential for debugging agents
|
||||
5. **Optimize chunk size** - 500-1000 chars for RAG
|
||||
6. **Version prompts** - Track changes in production
|
||||
7. **Cache embeddings** - Expensive, cache when possible
|
||||
8. **Monitor costs** - Track token usage with LangSmith
|
||||
|
||||
## Performance benchmarks
|
||||
|
||||
| Operation | Latency | Notes |
|
||||
|-----------|---------|-------|
|
||||
| Simple LLM call | ~1-2s | Depends on provider |
|
||||
| Agent with 1 tool | ~3-5s | ReAct reasoning overhead |
|
||||
| RAG retrieval | ~0.5-1s | Vector search + LLM |
|
||||
| Embedding 1000 docs | ~10-30s | Depends on model |
|
||||
|
||||
## LangChain vs LangGraph
|
||||
|
||||
| Feature | LangChain | LangGraph |
|
||||
|---------|-----------|-----------|
|
||||
| **Best for** | Quick agents, RAG | Complex workflows |
|
||||
| **Abstraction level** | High | Low |
|
||||
| **Code to start** | <10 lines | ~30 lines |
|
||||
| **Control** | Simple | Full control |
|
||||
| **Stateful workflows** | Limited | Native |
|
||||
| **Cyclic graphs** | No | Yes |
|
||||
| **Human-in-loop** | Basic | Advanced |
|
||||
|
||||
**Use LangGraph when:**
|
||||
- Need stateful workflows with cycles
|
||||
- Require fine-grained control
|
||||
- Building multi-agent systems
|
||||
- Production apps with complex logic
|
||||
|
||||
## References
|
||||
|
||||
- **[Agents Guide](references/agents.md)** - ReAct, tool calling, streaming
|
||||
- **[RAG Guide](references/rag.md)** - Document loaders, retrievers, QA chains
|
||||
- **[Integration Guide](references/integration.md)** - Vector stores, LangSmith, deployment
|
||||
|
||||
## Resources
|
||||
|
||||
- **GitHub**: https://github.com/langchain-ai/langchain ⭐ 119,000+
|
||||
- **Docs**: https://docs.langchain.com
|
||||
- **API Reference**: https://reference.langchain.com/python
|
||||
- **LangSmith**: https://smith.langchain.com (observability)
|
||||
- **Version**: 0.3+ (stable)
|
||||
- **License**: MIT
|
||||
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
# LangChain Agents Guide
|
||||
|
||||
Complete guide to building agents with ReAct, tool calling, and streaming.
|
||||
|
||||
## What are agents?
|
||||
|
||||
Agents combine language models with tools to solve complex tasks through reasoning and action:
|
||||
|
||||
1. **Reasoning**: LLM decides what to do
|
||||
2. **Acting**: Execute tools based on reasoning
|
||||
3. **Observation**: Receive tool results
|
||||
4. **Loop**: Repeat until task complete
|
||||
|
||||
This is the **ReAct pattern** (Reasoning + Acting).
|
||||
|
||||
## Basic agent creation
|
||||
|
||||
```python
|
||||
from langchain.agents import create_agent
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# Define tools
|
||||
def calculator(expression: str) -> str:
|
||||
"""Evaluate a math expression."""
|
||||
return str(eval(expression))
|
||||
|
||||
def search(query: str) -> str:
|
||||
"""Search for information."""
|
||||
return f"Results for: {query}"
|
||||
|
||||
# Create agent
|
||||
agent = create_agent(
|
||||
model=ChatAnthropic(model="claude-sonnet-4-5-20250929"),
|
||||
tools=[calculator, search],
|
||||
system_prompt="You are a helpful assistant. Use tools when needed."
|
||||
)
|
||||
|
||||
# Run agent
|
||||
result = agent.invoke({
|
||||
"messages": [{"role": "user", "content": "What is 25 * 17?"}]
|
||||
})
|
||||
print(result["messages"][-1].content)
|
||||
```
|
||||
|
||||
## Agent components
|
||||
|
||||
### 1. Model - The reasoning engine
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
# OpenAI
|
||||
model = ChatOpenAI(model="gpt-4o", temperature=0)
|
||||
|
||||
# Anthropic (better for complex reasoning)
|
||||
model = ChatAnthropic(model="claude-sonnet-4-5-20250929", temperature=0)
|
||||
|
||||
# Dynamic model selection
|
||||
def select_model(task_complexity: str):
|
||||
if task_complexity == "high":
|
||||
return ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
||||
else:
|
||||
return ChatOpenAI(model="gpt-4o-mini")
|
||||
```
|
||||
|
||||
### 2. Tools - Actions the agent can take
|
||||
|
||||
```python
|
||||
from langchain.tools import tool
|
||||
|
||||
# Simple function tool
|
||||
@tool
|
||||
def get_current_time() -> str:
|
||||
"""Get the current time."""
|
||||
from datetime import datetime
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
# Tool with parameters
|
||||
@tool
|
||||
def fetch_weather(city: str, units: str = "fahrenheit") -> str:
|
||||
"""Fetch weather for a city.
|
||||
|
||||
Args:
|
||||
city: City name
|
||||
units: Temperature units (fahrenheit or celsius)
|
||||
"""
|
||||
# Your weather API call here
|
||||
return f"Weather in {city}: 72°{units[0].upper()}"
|
||||
|
||||
# Tool with error handling
|
||||
@tool
|
||||
def risky_api_call(endpoint: str) -> str:
|
||||
"""Call an external API that might fail."""
|
||||
try:
|
||||
response = requests.get(endpoint, timeout=5)
|
||||
return response.text
|
||||
except Exception as e:
|
||||
return f"Error calling API: {str(e)}"
|
||||
```
|
||||
|
||||
### 3. System prompt - Agent behavior
|
||||
|
||||
```python
|
||||
# General assistant
|
||||
system_prompt = "You are a helpful assistant. Use tools when needed."
|
||||
|
||||
# Domain expert
|
||||
system_prompt = """You are a financial analyst assistant.
|
||||
- Use the calculator for precise calculations
|
||||
- Search for recent financial data
|
||||
- Provide data-driven recommendations
|
||||
- Always cite your sources"""
|
||||
|
||||
# Constrained agent
|
||||
system_prompt = """You are a customer support agent.
|
||||
- Only use search_kb tool to find answers
|
||||
- If answer not found, escalate to human
|
||||
- Be concise and professional
|
||||
- Never make up information"""
|
||||
```
|
||||
|
||||
## Agent types
|
||||
|
||||
### 1. Tool-calling agent (recommended)
|
||||
|
||||
Uses native function calling for best performance:
|
||||
|
||||
```python
|
||||
from langchain.agents import create_tool_calling_agent, AgentExecutor
|
||||
from langchain.prompts import ChatPromptTemplate
|
||||
|
||||
# Create prompt
|
||||
prompt = ChatPromptTemplate.from_messages([
|
||||
("system", "You are a helpful assistant"),
|
||||
("human", "{input}"),
|
||||
("placeholder", "{agent_scratchpad}"),
|
||||
])
|
||||
|
||||
# Create agent
|
||||
agent = create_tool_calling_agent(
|
||||
llm=model,
|
||||
tools=[calculator, search],
|
||||
prompt=prompt
|
||||
)
|
||||
|
||||
# Wrap in executor
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[calculator, search],
|
||||
verbose=True,
|
||||
max_iterations=5,
|
||||
handle_parsing_errors=True
|
||||
)
|
||||
|
||||
# Run
|
||||
result = agent_executor.invoke({"input": "What is the weather in Paris?"})
|
||||
```
|
||||
|
||||
### 2. ReAct agent (reasoning trace)
|
||||
|
||||
Shows step-by-step reasoning:
|
||||
|
||||
```python
|
||||
from langchain.agents import create_react_agent
|
||||
|
||||
# ReAct prompt shows thought process
|
||||
react_prompt = """Answer the following questions as best you can. You have access to the following tools:
|
||||
|
||||
{tools}
|
||||
|
||||
Use the following format:
|
||||
|
||||
Question: the input question you must answer
|
||||
Thought: you should always think about what to do
|
||||
Action: the action to take, should be one of [{tool_names}]
|
||||
Action Input: the input to the action
|
||||
Observation: the result of the action
|
||||
... (this Thought/Action/Action Input/Observation can repeat N times)
|
||||
Thought: I now know the final answer
|
||||
Final Answer: the final answer to the original input question
|
||||
|
||||
Begin!
|
||||
|
||||
Question: {input}
|
||||
Thought: {agent_scratchpad}"""
|
||||
|
||||
agent = create_react_agent(
|
||||
llm=model,
|
||||
tools=[calculator, search],
|
||||
prompt=ChatPromptTemplate.from_template(react_prompt)
|
||||
)
|
||||
|
||||
# Run with visible reasoning
|
||||
result = agent_executor.invoke({"input": "What is 25 * 17 + 142?"})
|
||||
```
|
||||
|
||||
### 3. Conversational agent (with memory)
|
||||
|
||||
Remembers conversation history:
|
||||
|
||||
```python
|
||||
from langchain.agents import create_conversational_retrieval_agent
|
||||
from langchain.memory import ConversationBufferMemory
|
||||
|
||||
# Add memory
|
||||
memory = ConversationBufferMemory(
|
||||
memory_key="chat_history",
|
||||
return_messages=True
|
||||
)
|
||||
|
||||
# Conversational agent
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[calculator, search],
|
||||
memory=memory,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Multi-turn conversation
|
||||
agent_executor.invoke({"input": "My name is Alice"})
|
||||
agent_executor.invoke({"input": "What's my name?"}) # Remembers "Alice"
|
||||
agent_executor.invoke({"input": "What is 25 * 17?"})
|
||||
```
|
||||
|
||||
## Tool execution patterns
|
||||
|
||||
### Parallel tool execution
|
||||
|
||||
```python
|
||||
# Agent automatically parallelizes independent calls
|
||||
agent = create_tool_calling_agent(llm=model, tools=[get_weather, search])
|
||||
|
||||
# This calls get_weather("Paris") and get_weather("London") in parallel
|
||||
result = agent_executor.invoke({
|
||||
"input": "Compare weather in Paris and London"
|
||||
})
|
||||
```
|
||||
|
||||
### Sequential tool chaining
|
||||
|
||||
```python
|
||||
# Agent chains tools automatically
|
||||
@tool
|
||||
def search_company(name: str) -> str:
|
||||
"""Search for company information."""
|
||||
return f"Company ID: 12345, Industry: Tech"
|
||||
|
||||
@tool
|
||||
def get_stock_price(company_id: str) -> str:
|
||||
"""Get stock price for a company."""
|
||||
return f"${150.00}"
|
||||
|
||||
# Agent will: search_company → get_stock_price
|
||||
result = agent_executor.invoke({
|
||||
"input": "What is Apple's current stock price?"
|
||||
})
|
||||
```
|
||||
|
||||
### Conditional tool usage
|
||||
|
||||
```python
|
||||
# Agent decides when to use tools
|
||||
@tool
|
||||
def expensive_tool(query: str) -> str:
|
||||
"""Use only when necessary - costs $0.10 per call."""
|
||||
return perform_expensive_operation(query)
|
||||
|
||||
# Agent uses tool only if needed
|
||||
result = agent_executor.invoke({
|
||||
"input": "What is 2+2?" # Won't use expensive_tool
|
||||
})
|
||||
```
|
||||
|
||||
## Streaming
|
||||
|
||||
### Stream agent steps
|
||||
|
||||
```python
|
||||
# Stream intermediate steps
|
||||
for step in agent_executor.stream({"input": "Research quantum computing"}):
|
||||
if "actions" in step:
|
||||
action = step["actions"][0]
|
||||
print(f"Tool: {action.tool}, Input: {action.tool_input}")
|
||||
if "steps" in step:
|
||||
print(f"Observation: {step['steps'][0].observation}")
|
||||
if "output" in step:
|
||||
print(f"Final: {step['output']}")
|
||||
```
|
||||
|
||||
### Stream LLM tokens
|
||||
|
||||
```python
|
||||
from langchain.callbacks import StreamingStdOutCallbackHandler
|
||||
|
||||
# Stream model responses
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[calculator],
|
||||
callbacks=[StreamingStdOutCallbackHandler()],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
result = agent_executor.invoke({"input": "Explain quantum computing"})
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
### Tool error handling
|
||||
|
||||
```python
|
||||
@tool
|
||||
def fallible_tool(query: str) -> str:
|
||||
"""A tool that might fail."""
|
||||
try:
|
||||
result = risky_operation(query)
|
||||
return f"Success: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}. Please try a different approach."
|
||||
|
||||
# Agent adapts to errors
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[fallible_tool],
|
||||
handle_parsing_errors=True, # Handle malformed tool calls
|
||||
max_iterations=5
|
||||
)
|
||||
```
|
||||
|
||||
### Timeout handling
|
||||
|
||||
```python
|
||||
from langchain.callbacks import TimeoutCallback
|
||||
|
||||
# Set timeout
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[slow_tool],
|
||||
callbacks=[TimeoutCallback(timeout=30)], # 30 second timeout
|
||||
max_iterations=10
|
||||
)
|
||||
```
|
||||
|
||||
### Retry logic
|
||||
|
||||
```python
|
||||
from langchain.callbacks import RetryCallback
|
||||
|
||||
# Retry on failure
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[unreliable_tool],
|
||||
callbacks=[RetryCallback(max_retries=3)],
|
||||
max_execution_time=60
|
||||
)
|
||||
```
|
||||
|
||||
## Advanced patterns
|
||||
|
||||
### Dynamic tool selection
|
||||
|
||||
```python
|
||||
# Select tools based on context
|
||||
def get_tools_for_user(user_role: str):
|
||||
if user_role == "admin":
|
||||
return [search, calculator, database_query, delete_data]
|
||||
elif user_role == "analyst":
|
||||
return [search, calculator, database_query]
|
||||
else:
|
||||
return [search, calculator]
|
||||
|
||||
# Create agent with role-based tools
|
||||
tools = get_tools_for_user(current_user.role)
|
||||
agent = create_agent(model=model, tools=tools)
|
||||
```
|
||||
|
||||
### Multi-step reasoning
|
||||
|
||||
```python
|
||||
# Agent plans multiple steps
|
||||
system_prompt = """Break down complex tasks into steps:
|
||||
1. Analyze the question
|
||||
2. Determine required information
|
||||
3. Use tools to gather data
|
||||
4. Synthesize findings
|
||||
5. Provide final answer"""
|
||||
|
||||
agent = create_agent(
|
||||
model=model,
|
||||
tools=[search, calculator, database],
|
||||
system_prompt=system_prompt
|
||||
)
|
||||
|
||||
result = agent.invoke({
|
||||
"input": "Compare revenue growth of top 3 tech companies over 5 years"
|
||||
})
|
||||
```
|
||||
|
||||
### Structured output from agents
|
||||
|
||||
```python
|
||||
from langchain_core.pydantic_v1 import BaseModel, Field
|
||||
|
||||
class ResearchReport(BaseModel):
|
||||
summary: str = Field(description="Executive summary")
|
||||
findings: list[str] = Field(description="Key findings")
|
||||
sources: list[str] = Field(description="Source URLs")
|
||||
|
||||
# Agent returns structured output
|
||||
structured_agent = agent.with_structured_output(ResearchReport)
|
||||
report = structured_agent.invoke({"input": "Research AI safety"})
|
||||
print(report.summary, report.findings)
|
||||
```
|
||||
|
||||
## Middleware & customization
|
||||
|
||||
### Custom agent middleware
|
||||
|
||||
```python
|
||||
from langchain.agents import AgentExecutor
|
||||
|
||||
def logging_middleware(agent_executor):
|
||||
"""Log all agent actions."""
|
||||
original_invoke = agent_executor.invoke
|
||||
|
||||
def wrapped_invoke(*args, **kwargs):
|
||||
print(f"Agent invoked with: {args[0]}")
|
||||
result = original_invoke(*args, **kwargs)
|
||||
print(f"Agent result: {result}")
|
||||
return result
|
||||
|
||||
agent_executor.invoke = wrapped_invoke
|
||||
return agent_executor
|
||||
|
||||
# Apply middleware
|
||||
agent_executor = logging_middleware(agent_executor)
|
||||
```
|
||||
|
||||
### Custom stopping conditions
|
||||
|
||||
```python
|
||||
from langchain.agents import EarlyStoppingMethod
|
||||
|
||||
# Stop early if confident
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[search],
|
||||
early_stopping_method=EarlyStoppingMethod.GENERATE, # or FORCE
|
||||
max_iterations=10
|
||||
)
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Use tool-calling agents** - Fastest and most reliable
|
||||
2. **Keep tool descriptions clear** - Agent needs to understand when to use each tool
|
||||
3. **Add error handling** - Tools will fail, handle gracefully
|
||||
4. **Set max_iterations** - Prevent infinite loops (default: 15)
|
||||
5. **Enable streaming** - Better UX for long tasks
|
||||
6. **Use verbose=True during dev** - See agent reasoning
|
||||
7. **Test tool combinations** - Ensure tools work together
|
||||
8. **Monitor with LangSmith** - Essential for production
|
||||
9. **Cache tool results** - Avoid redundant API calls
|
||||
10. **Version system prompts** - Track changes in behavior
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
1. **Vague tool descriptions** - Agent won't know when to use tool
|
||||
2. **Too many tools** - Agent gets confused (limit to 5-10)
|
||||
3. **Tools without error handling** - One failure crashes agent
|
||||
4. **Circular tool dependencies** - Agent gets stuck in loops
|
||||
5. **Missing max_iterations** - Agent runs forever
|
||||
6. **Poor system prompts** - Agent doesn't follow instructions
|
||||
|
||||
## Debugging agents
|
||||
|
||||
```python
|
||||
# Enable verbose logging
|
||||
agent_executor = AgentExecutor(
|
||||
agent=agent,
|
||||
tools=[calculator],
|
||||
verbose=True, # See all steps
|
||||
return_intermediate_steps=True # Get full trace
|
||||
)
|
||||
|
||||
result = agent_executor.invoke({"input": "Calculate 25 * 17"})
|
||||
|
||||
# Inspect intermediate steps
|
||||
for step in result["intermediate_steps"]:
|
||||
print(f"Action: {step[0].tool}")
|
||||
print(f"Input: {step[0].tool_input}")
|
||||
print(f"Output: {step[1]}")
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **ReAct Paper**: https://arxiv.org/abs/2210.03629
|
||||
- **LangChain Agents Docs**: https://docs.langchain.com/oss/python/langchain/agents
|
||||
- **LangSmith Debugging**: https://smith.langchain.com
|
||||
@@ -0,0 +1,562 @@
|
||||
# LangChain Integration Guide
|
||||
|
||||
Integration with vector stores, LangSmith observability, and deployment.
|
||||
|
||||
## Vector store integrations
|
||||
|
||||
### Chroma (local, open-source)
|
||||
|
||||
```python
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
|
||||
# Create vector store
|
||||
vectorstore = Chroma.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
persist_directory="./chroma_db"
|
||||
)
|
||||
|
||||
# Load existing store
|
||||
vectorstore = Chroma(
|
||||
persist_directory="./chroma_db",
|
||||
embedding_function=OpenAIEmbeddings()
|
||||
)
|
||||
|
||||
# Add documents incrementally
|
||||
vectorstore.add_documents([new_doc1, new_doc2])
|
||||
|
||||
# Delete documents
|
||||
vectorstore.delete(ids=["doc1", "doc2"])
|
||||
```
|
||||
|
||||
### Pinecone (cloud, scalable)
|
||||
|
||||
```python
|
||||
from langchain_pinecone import PineconeVectorStore
|
||||
import pinecone
|
||||
|
||||
# Initialize Pinecone
|
||||
pinecone.init(api_key="your-api-key", environment="us-west1-gcp")
|
||||
|
||||
# Create index (one-time)
|
||||
pinecone.create_index("my-index", dimension=1536, metric="cosine")
|
||||
|
||||
# Create vector store
|
||||
vectorstore = PineconeVectorStore.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
index_name="my-index"
|
||||
)
|
||||
|
||||
# Query with metadata filters
|
||||
results = vectorstore.similarity_search(
|
||||
"Python tutorials",
|
||||
k=4,
|
||||
filter={"category": "beginner"}
|
||||
)
|
||||
```
|
||||
|
||||
### FAISS (fast similarity search)
|
||||
|
||||
```python
|
||||
from langchain_community.vectorstores import FAISS
|
||||
|
||||
# Create FAISS index
|
||||
vectorstore = FAISS.from_documents(docs, OpenAIEmbeddings())
|
||||
|
||||
# Save to disk
|
||||
vectorstore.save_local("./faiss_index")
|
||||
|
||||
# Load from disk
|
||||
vectorstore = FAISS.load_local(
|
||||
"./faiss_index",
|
||||
OpenAIEmbeddings(),
|
||||
allow_dangerous_deserialization=True
|
||||
)
|
||||
|
||||
# Merge multiple indices
|
||||
vectorstore1 = FAISS.load_local("./index1", embeddings)
|
||||
vectorstore2 = FAISS.load_local("./index2", embeddings)
|
||||
vectorstore1.merge_from(vectorstore2)
|
||||
```
|
||||
|
||||
### Weaviate (production, ML-native)
|
||||
|
||||
```python
|
||||
from langchain_weaviate import WeaviateVectorStore
|
||||
import weaviate
|
||||
|
||||
# Connect to Weaviate
|
||||
client = weaviate.Client("http://localhost:8080")
|
||||
|
||||
# Create vector store
|
||||
vectorstore = WeaviateVectorStore.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
client=client,
|
||||
index_name="LangChain"
|
||||
)
|
||||
|
||||
# Hybrid search (vector + keyword)
|
||||
results = vectorstore.similarity_search(
|
||||
"Python async",
|
||||
k=4,
|
||||
alpha=0.5 # 0=keyword, 1=vector, 0.5=hybrid
|
||||
)
|
||||
```
|
||||
|
||||
### Qdrant (fast, open-source)
|
||||
|
||||
```python
|
||||
from langchain_qdrant import QdrantVectorStore
|
||||
from qdrant_client import QdrantClient
|
||||
|
||||
# Connect to Qdrant
|
||||
client = QdrantClient(host="localhost", port=6333)
|
||||
|
||||
# Create vector store
|
||||
vectorstore = QdrantVectorStore.from_documents(
|
||||
documents=docs,
|
||||
embedding=OpenAIEmbeddings(),
|
||||
collection_name="my_documents",
|
||||
client=client
|
||||
)
|
||||
```
|
||||
|
||||
## LangSmith observability
|
||||
|
||||
### Enable tracing
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
# Set environment variables
|
||||
os.environ["LANGCHAIN_TRACING_V2"] = "true"
|
||||
os.environ["LANGCHAIN_API_KEY"] = "your-langsmith-api-key"
|
||||
os.environ["LANGCHAIN_PROJECT"] = "my-project"
|
||||
|
||||
# All chains/agents automatically traced
|
||||
from langchain.agents import create_agent
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
agent = create_agent(
|
||||
model=ChatAnthropic(model="claude-sonnet-4-5-20250929"),
|
||||
tools=[calculator, search]
|
||||
)
|
||||
|
||||
# Run - automatically logged to LangSmith
|
||||
result = agent.invoke({"input": "What is 25 * 17?"})
|
||||
|
||||
# View traces at https://smith.langchain.com
|
||||
```
|
||||
|
||||
### Custom metadata
|
||||
|
||||
```python
|
||||
from langchain.callbacks import tracing_v2_enabled
|
||||
|
||||
# Add custom metadata to traces
|
||||
with tracing_v2_enabled(
|
||||
project_name="my-project",
|
||||
tags=["production", "customer-support"],
|
||||
metadata={"user_id": "12345", "session_id": "abc"}
|
||||
):
|
||||
result = agent.invoke({"input": "Help me with Python"})
|
||||
```
|
||||
|
||||
### Evaluate runs
|
||||
|
||||
```python
|
||||
from langsmith import Client
|
||||
|
||||
client = Client()
|
||||
|
||||
# Create dataset
|
||||
dataset = client.create_dataset("qa-eval")
|
||||
client.create_example(
|
||||
dataset_id=dataset.id,
|
||||
inputs={"question": "What is Python?"},
|
||||
outputs={"answer": "Python is a programming language"}
|
||||
)
|
||||
|
||||
# Evaluate
|
||||
from langchain.evaluation import load_evaluator
|
||||
|
||||
evaluator = load_evaluator("qa")
|
||||
results = client.evaluate(
|
||||
lambda x: qa_chain(x),
|
||||
data=dataset,
|
||||
evaluators=[evaluator]
|
||||
)
|
||||
```
|
||||
|
||||
## Deployment patterns
|
||||
|
||||
### FastAPI server
|
||||
|
||||
```python
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from langchain.agents import create_agent
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Initialize agent once
|
||||
agent = create_agent(
|
||||
model=llm,
|
||||
tools=[search, calculator]
|
||||
)
|
||||
|
||||
class Query(BaseModel):
|
||||
input: str
|
||||
|
||||
@app.post("/chat")
|
||||
async def chat(query: Query):
|
||||
result = agent.invoke({"input": query.input})
|
||||
return {"response": result["output"]}
|
||||
|
||||
# Run: uvicorn main:app --reload
|
||||
```
|
||||
|
||||
### Streaming responses
|
||||
|
||||
```python
|
||||
from fastapi.responses import StreamingResponse
|
||||
from langchain.callbacks import AsyncIteratorCallbackHandler
|
||||
|
||||
@app.post("/chat/stream")
|
||||
async def chat_stream(query: Query):
|
||||
callback = AsyncIteratorCallbackHandler()
|
||||
|
||||
async def generate():
|
||||
async for token in agent.astream({"input": query.input}):
|
||||
if "output" in token:
|
||||
yield token["output"]
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/plain")
|
||||
```
|
||||
|
||||
### Docker deployment
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker build -t langchain-app .
|
||||
docker run -p 8000:8000 \
|
||||
-e OPENAI_API_KEY=your-key \
|
||||
-e LANGCHAIN_API_KEY=your-key \
|
||||
langchain-app
|
||||
```
|
||||
|
||||
### Kubernetes deployment
|
||||
|
||||
```yaml
|
||||
# deployment.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: langchain-app
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: langchain
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: langchain
|
||||
spec:
|
||||
containers:
|
||||
- name: langchain
|
||||
image: your-registry/langchain-app:latest
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
env:
|
||||
- name: OPENAI_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: langchain-secrets
|
||||
key: openai-api-key
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "2000m"
|
||||
```
|
||||
|
||||
## Model integrations
|
||||
|
||||
### OpenAI
|
||||
|
||||
```python
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
llm = ChatOpenAI(
|
||||
model="gpt-4o",
|
||||
temperature=0,
|
||||
max_tokens=1000,
|
||||
timeout=30,
|
||||
max_retries=2
|
||||
)
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
```python
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
llm = ChatAnthropic(
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
temperature=0,
|
||||
max_tokens=4096,
|
||||
timeout=60
|
||||
)
|
||||
```
|
||||
|
||||
### Google
|
||||
|
||||
```python
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
|
||||
llm = ChatGoogleGenerativeAI(
|
||||
model="gemini-2.0-flash-exp",
|
||||
temperature=0
|
||||
)
|
||||
```
|
||||
|
||||
### Local models (Ollama)
|
||||
|
||||
```python
|
||||
from langchain_community.llms import Ollama
|
||||
|
||||
llm = Ollama(
|
||||
model="llama3",
|
||||
base_url="http://localhost:11434"
|
||||
)
|
||||
```
|
||||
|
||||
### Azure OpenAI
|
||||
|
||||
```python
|
||||
from langchain_openai import AzureChatOpenAI
|
||||
|
||||
llm = AzureChatOpenAI(
|
||||
azure_endpoint="https://your-endpoint.openai.azure.com/",
|
||||
azure_deployment="gpt-4",
|
||||
api_version="2024-02-15-preview"
|
||||
)
|
||||
```
|
||||
|
||||
## Tool integrations
|
||||
|
||||
### Web search
|
||||
|
||||
```python
|
||||
from langchain_community.tools import DuckDuckGoSearchRun, TavilySearchResults
|
||||
|
||||
# DuckDuckGo (free)
|
||||
search = DuckDuckGoSearchRun()
|
||||
|
||||
# Tavily (best quality)
|
||||
search = TavilySearchResults(api_key="your-key")
|
||||
```
|
||||
|
||||
### Wikipedia
|
||||
|
||||
```python
|
||||
from langchain_community.tools import WikipediaQueryRun
|
||||
from langchain_community.utilities import WikipediaAPIWrapper
|
||||
|
||||
wikipedia = WikipediaQueryRun(api_wrapper=WikipediaAPIWrapper())
|
||||
```
|
||||
|
||||
### Python REPL
|
||||
|
||||
```python
|
||||
from langchain_experimental.tools import PythonREPLTool
|
||||
|
||||
python_repl = PythonREPLTool()
|
||||
|
||||
# Agent can execute Python code
|
||||
agent = create_agent(model=llm, tools=[python_repl])
|
||||
result = agent.invoke({"input": "Calculate the 10th Fibonacci number"})
|
||||
```
|
||||
|
||||
### Shell commands
|
||||
|
||||
```python
|
||||
from langchain_community.tools import ShellTool
|
||||
|
||||
shell = ShellTool()
|
||||
|
||||
# Agent can run shell commands
|
||||
agent = create_agent(model=llm, tools=[shell])
|
||||
```
|
||||
|
||||
### SQL databases
|
||||
|
||||
```python
|
||||
from langchain_community.utilities import SQLDatabase
|
||||
from langchain_community.agent_toolkits import create_sql_agent
|
||||
|
||||
db = SQLDatabase.from_uri("sqlite:///mydatabase.db")
|
||||
|
||||
agent = create_sql_agent(
|
||||
llm=llm,
|
||||
db=db,
|
||||
agent_type="openai-tools",
|
||||
verbose=True
|
||||
)
|
||||
|
||||
result = agent.run("How many users are in the database?")
|
||||
```
|
||||
|
||||
## Memory integrations
|
||||
|
||||
### Redis
|
||||
|
||||
```python
|
||||
from langchain.memory import RedisChatMessageHistory
|
||||
from langchain.memory import ConversationBufferMemory
|
||||
|
||||
# Redis-backed memory
|
||||
message_history = RedisChatMessageHistory(
|
||||
url="redis://localhost:6379",
|
||||
session_id="user-123"
|
||||
)
|
||||
|
||||
memory = ConversationBufferMemory(
|
||||
chat_memory=message_history,
|
||||
return_messages=True
|
||||
)
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```python
|
||||
from langchain_postgres import PostgresChatMessageHistory
|
||||
|
||||
message_history = PostgresChatMessageHistory(
|
||||
connection_string="postgresql://user:pass@localhost/db",
|
||||
session_id="user-123"
|
||||
)
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
```python
|
||||
from langchain_mongodb import MongoDBChatMessageHistory
|
||||
|
||||
message_history = MongoDBChatMessageHistory(
|
||||
connection_string="mongodb://localhost:27017/",
|
||||
session_id="user-123"
|
||||
)
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
### In-memory cache
|
||||
|
||||
```python
|
||||
from langchain.cache import InMemoryCache
|
||||
from langchain.globals import set_llm_cache
|
||||
|
||||
set_llm_cache(InMemoryCache())
|
||||
|
||||
# Same query uses cache
|
||||
response1 = llm.invoke("What is Python?") # API call
|
||||
response2 = llm.invoke("What is Python?") # Cached
|
||||
```
|
||||
|
||||
### SQLite cache
|
||||
|
||||
```python
|
||||
from langchain.cache import SQLiteCache
|
||||
|
||||
set_llm_cache(SQLiteCache(database_path=".langchain.db"))
|
||||
```
|
||||
|
||||
### Redis cache
|
||||
|
||||
```python
|
||||
from langchain.cache import RedisCache
|
||||
from redis import Redis
|
||||
|
||||
set_llm_cache(RedisCache(redis_=Redis(host="localhost", port=6379)))
|
||||
```
|
||||
|
||||
## Monitoring & logging
|
||||
|
||||
### Custom callbacks
|
||||
|
||||
```python
|
||||
from langchain.callbacks.base import BaseCallbackHandler
|
||||
|
||||
class CustomCallback(BaseCallbackHandler):
|
||||
def on_llm_start(self, serialized, prompts, **kwargs):
|
||||
print(f"LLM started with prompts: {prompts}")
|
||||
|
||||
def on_llm_end(self, response, **kwargs):
|
||||
print(f"LLM finished with: {response}")
|
||||
|
||||
def on_tool_start(self, serialized, input_str, **kwargs):
|
||||
print(f"Tool {serialized['name']} started with: {input_str}")
|
||||
|
||||
def on_tool_end(self, output, **kwargs):
|
||||
print(f"Tool finished with: {output}")
|
||||
|
||||
# Use callback
|
||||
agent = create_agent(
|
||||
model=llm,
|
||||
tools=[calculator],
|
||||
callbacks=[CustomCallback()]
|
||||
)
|
||||
```
|
||||
|
||||
### Token counting
|
||||
|
||||
```python
|
||||
from langchain.callbacks import get_openai_callback
|
||||
|
||||
with get_openai_callback() as cb:
|
||||
result = llm.invoke("Write a long story")
|
||||
print(f"Tokens used: {cb.total_tokens}")
|
||||
print(f"Cost: ${cb.total_cost:.4f}")
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Use LangSmith in production** - Essential for debugging
|
||||
2. **Cache aggressively** - LLM calls are expensive
|
||||
3. **Set timeouts** - Prevent hanging requests
|
||||
4. **Add retries** - Handle transient failures
|
||||
5. **Monitor costs** - Track token usage
|
||||
6. **Version your prompts** - Track changes
|
||||
7. **Use async** - Better performance for I/O
|
||||
8. **Persistent memory** - Don't lose conversation history
|
||||
9. **Secure API keys** - Use environment variables
|
||||
10. **Test integrations** - Verify connections before production
|
||||
|
||||
## Resources
|
||||
|
||||
- **LangSmith**: https://smith.langchain.com
|
||||
- **Vector Stores**: https://python.langchain.com/docs/integrations/vectorstores
|
||||
- **Model Providers**: https://python.langchain.com/docs/integrations/llms
|
||||
- **Tools**: https://python.langchain.com/docs/integrations/tools
|
||||
- **Deployment Guide**: https://docs.langchain.com/deploy
|
||||
@@ -0,0 +1,600 @@
|
||||
# LangChain RAG Guide
|
||||
|
||||
Complete guide to Retrieval-Augmented Generation with LangChain.
|
||||
|
||||
## What is RAG?
|
||||
|
||||
**RAG (Retrieval-Augmented Generation)** combines:
|
||||
1. **Retrieval**: Find relevant documents from knowledge base
|
||||
2. **Generation**: LLM generates answer using retrieved context
|
||||
|
||||
**Benefits**:
|
||||
- Reduce hallucinations
|
||||
- Up-to-date information
|
||||
- Domain-specific knowledge
|
||||
- Source citations
|
||||
|
||||
## RAG pipeline components
|
||||
|
||||
### 1. Document loading
|
||||
|
||||
```python
|
||||
from langchain_community.document_loaders import (
|
||||
WebBaseLoader,
|
||||
PyPDFLoader,
|
||||
TextLoader,
|
||||
DirectoryLoader,
|
||||
CSVLoader,
|
||||
UnstructuredMarkdownLoader
|
||||
)
|
||||
|
||||
# Web pages
|
||||
loader = WebBaseLoader("https://docs.python.org/3/tutorial/")
|
||||
docs = loader.load()
|
||||
|
||||
# PDF files
|
||||
loader = PyPDFLoader("paper.pdf")
|
||||
docs = loader.load()
|
||||
|
||||
# Multiple PDFs
|
||||
loader = DirectoryLoader("./papers/", glob="**/*.pdf", loader_cls=PyPDFLoader)
|
||||
docs = loader.load()
|
||||
|
||||
# Text files
|
||||
loader = TextLoader("data.txt")
|
||||
docs = loader.load()
|
||||
|
||||
# CSV
|
||||
loader = CSVLoader("data.csv")
|
||||
docs = loader.load()
|
||||
|
||||
# Markdown
|
||||
loader = UnstructuredMarkdownLoader("README.md")
|
||||
docs = loader.load()
|
||||
```
|
||||
|
||||
### 2. Text splitting
|
||||
|
||||
```python
|
||||
from langchain.text_splitter import (
|
||||
RecursiveCharacterTextSplitter,
|
||||
CharacterTextSplitter,
|
||||
TokenTextSplitter
|
||||
)
|
||||
|
||||
# Recommended: Recursive (tries multiple separators)
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=1000, # Characters per chunk
|
||||
chunk_overlap=200, # Overlap between chunks
|
||||
length_function=len,
|
||||
separators=["\n\n", "\n", " ", ""]
|
||||
)
|
||||
|
||||
splits = text_splitter.split_documents(docs)
|
||||
|
||||
# Token-based (for precise token limits)
|
||||
text_splitter = TokenTextSplitter(
|
||||
chunk_size=512, # Tokens per chunk
|
||||
chunk_overlap=50
|
||||
)
|
||||
|
||||
# Character-based (simple)
|
||||
text_splitter = CharacterTextSplitter(
|
||||
chunk_size=1000,
|
||||
chunk_overlap=200,
|
||||
separator="\n\n"
|
||||
)
|
||||
```
|
||||
|
||||
**Chunk size recommendations**:
|
||||
- **Short answers**: 256-512 tokens
|
||||
- **General Q&A**: 512-1024 tokens (recommended)
|
||||
- **Long context**: 1024-2048 tokens
|
||||
- **Overlap**: 10-20% of chunk_size
|
||||
|
||||
### 3. Embeddings
|
||||
|
||||
```python
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from langchain_community.embeddings import (
|
||||
HuggingFaceEmbeddings,
|
||||
CohereEmbeddings
|
||||
)
|
||||
|
||||
# OpenAI (fast, high quality)
|
||||
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
|
||||
|
||||
# HuggingFace (free, local)
|
||||
embeddings = HuggingFaceEmbeddings(
|
||||
model_name="sentence-transformers/all-mpnet-base-v2"
|
||||
)
|
||||
|
||||
# Cohere
|
||||
embeddings = CohereEmbeddings(model="embed-english-v3.0")
|
||||
```
|
||||
|
||||
### 4. Vector stores
|
||||
|
||||
```python
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_community.vectorstores import FAISS
|
||||
from langchain_pinecone import PineconeVectorStore
|
||||
|
||||
# Chroma (local, persistent)
|
||||
vectorstore = Chroma.from_documents(
|
||||
documents=splits,
|
||||
embedding=embeddings,
|
||||
persist_directory="./chroma_db"
|
||||
)
|
||||
|
||||
# FAISS (fast similarity search)
|
||||
vectorstore = FAISS.from_documents(splits, embeddings)
|
||||
vectorstore.save_local("./faiss_index")
|
||||
|
||||
# Pinecone (cloud, scalable)
|
||||
vectorstore = PineconeVectorStore.from_documents(
|
||||
documents=splits,
|
||||
embedding=embeddings,
|
||||
index_name="my-index"
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Retrieval
|
||||
|
||||
```python
|
||||
# Basic retriever (top-k similarity)
|
||||
retriever = vectorstore.as_retriever(
|
||||
search_type="similarity",
|
||||
search_kwargs={"k": 4} # Return top 4 documents
|
||||
)
|
||||
|
||||
# MMR (Maximal Marginal Relevance) - diverse results
|
||||
retriever = vectorstore.as_retriever(
|
||||
search_type="mmr",
|
||||
search_kwargs={
|
||||
"k": 4,
|
||||
"fetch_k": 20, # Fetch 20, return diverse 4
|
||||
"lambda_mult": 0.5 # Diversity (0=diverse, 1=similar)
|
||||
}
|
||||
)
|
||||
|
||||
# Similarity score threshold
|
||||
retriever = vectorstore.as_retriever(
|
||||
search_type="similarity_score_threshold",
|
||||
search_kwargs={
|
||||
"score_threshold": 0.5 # Minimum similarity score
|
||||
}
|
||||
)
|
||||
|
||||
# Query documents directly
|
||||
docs = retriever.get_relevant_documents("What is Python?")
|
||||
```
|
||||
|
||||
### 6. QA chain
|
||||
|
||||
```python
|
||||
from langchain.chains import RetrievalQA
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929")
|
||||
|
||||
# Basic QA chain
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
return_source_documents=True
|
||||
)
|
||||
|
||||
# Query
|
||||
result = qa_chain({"query": "What are Python decorators?"})
|
||||
print(result["result"])
|
||||
print(f"Sources: {len(result['source_documents'])}")
|
||||
```
|
||||
|
||||
## Advanced RAG patterns
|
||||
|
||||
### Conversational RAG
|
||||
|
||||
```python
|
||||
from langchain.chains import ConversationalRetrievalChain
|
||||
from langchain.memory import ConversationBufferMemory
|
||||
|
||||
# Add memory
|
||||
memory = ConversationBufferMemory(
|
||||
memory_key="chat_history",
|
||||
return_messages=True,
|
||||
output_key="answer"
|
||||
)
|
||||
|
||||
# Conversational RAG chain
|
||||
qa = ConversationalRetrievalChain.from_llm(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
memory=memory,
|
||||
return_source_documents=True
|
||||
)
|
||||
|
||||
# Multi-turn conversation
|
||||
result1 = qa({"question": "What is Python used for?"})
|
||||
result2 = qa({"question": "Can you give examples?"}) # Remembers context
|
||||
result3 = qa({"question": "What about web development?"})
|
||||
```
|
||||
|
||||
### Custom prompt template
|
||||
|
||||
```python
|
||||
from langchain.prompts import PromptTemplate
|
||||
|
||||
# Custom QA prompt
|
||||
template = """Use the following pieces of context to answer the question.
|
||||
If you don't know the answer, say so - don't make it up.
|
||||
Always cite your sources using [Source N] notation.
|
||||
|
||||
Context: {context}
|
||||
|
||||
Question: {question}
|
||||
|
||||
Helpful Answer:"""
|
||||
|
||||
prompt = PromptTemplate(
|
||||
template=template,
|
||||
input_variables=["context", "question"]
|
||||
)
|
||||
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
chain_type_kwargs={"prompt": prompt}
|
||||
)
|
||||
```
|
||||
|
||||
### Chain types
|
||||
|
||||
```python
|
||||
# 1. Stuff (default) - Put all docs in context
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
chain_type="stuff" # Fast, works if docs fit in context
|
||||
)
|
||||
|
||||
# 2. Map-reduce - Summarize each doc, then combine
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
chain_type="map_reduce" # For many documents
|
||||
)
|
||||
|
||||
# 3. Refine - Iteratively refine answer
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
chain_type="refine" # Most thorough, slowest
|
||||
)
|
||||
|
||||
# 4. Map-rerank - Score answers, return best
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
chain_type="map_rerank" # Good for multiple perspectives
|
||||
)
|
||||
```
|
||||
|
||||
### Multi-query retrieval
|
||||
|
||||
```python
|
||||
from langchain.retrievers import MultiQueryRetriever
|
||||
|
||||
# Generate multiple queries for better recall
|
||||
retriever = MultiQueryRetriever.from_llm(
|
||||
retriever=vectorstore.as_retriever(),
|
||||
llm=llm
|
||||
)
|
||||
|
||||
# "What is Python?" becomes:
|
||||
# - "What is Python programming language?"
|
||||
# - "Python language definition"
|
||||
# - "Overview of Python"
|
||||
docs = retriever.get_relevant_documents("What is Python?")
|
||||
```
|
||||
|
||||
### Contextual compression
|
||||
|
||||
```python
|
||||
from langchain.retrievers import ContextualCompressionRetriever
|
||||
from langchain.retrievers.document_compressors import LLMChainExtractor
|
||||
|
||||
# Compress retrieved docs to relevant parts only
|
||||
compressor = LLMChainExtractor.from_llm(llm)
|
||||
|
||||
compression_retriever = ContextualCompressionRetriever(
|
||||
base_compressor=compressor,
|
||||
base_retriever=vectorstore.as_retriever()
|
||||
)
|
||||
|
||||
# Returns only relevant excerpts
|
||||
compressed_docs = compression_retriever.get_relevant_documents("Python decorators")
|
||||
```
|
||||
|
||||
### Ensemble retrieval (hybrid search)
|
||||
|
||||
```python
|
||||
from langchain.retrievers import EnsembleRetriever
|
||||
from langchain.retrievers import BM25Retriever
|
||||
|
||||
# Vector search (semantic)
|
||||
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
|
||||
|
||||
# Keyword search (BM25)
|
||||
keyword_retriever = BM25Retriever.from_documents(splits)
|
||||
keyword_retriever.k = 5
|
||||
|
||||
# Combine both
|
||||
ensemble_retriever = EnsembleRetriever(
|
||||
retrievers=[vector_retriever, keyword_retriever],
|
||||
weights=[0.5, 0.5] # Equal weight
|
||||
)
|
||||
|
||||
docs = ensemble_retriever.get_relevant_documents("Python async")
|
||||
```
|
||||
|
||||
## RAG with agents
|
||||
|
||||
### Agent-based RAG
|
||||
|
||||
```python
|
||||
from langchain.agents import create_tool_calling_agent
|
||||
from langchain.tools.retriever import create_retriever_tool
|
||||
|
||||
# Create retriever tool
|
||||
retriever_tool = create_retriever_tool(
|
||||
retriever=retriever,
|
||||
name="python_docs",
|
||||
description="Searches Python documentation for answers about Python programming"
|
||||
)
|
||||
|
||||
# Create agent with retriever tool
|
||||
agent = create_tool_calling_agent(
|
||||
llm=llm,
|
||||
tools=[retriever_tool, calculator, search],
|
||||
system_prompt="Use python_docs tool for Python questions"
|
||||
)
|
||||
|
||||
# Agent decides when to retrieve
|
||||
from langchain.agents import AgentExecutor
|
||||
agent_executor = AgentExecutor(agent=agent, tools=[retriever_tool])
|
||||
|
||||
result = agent_executor.invoke({"input": "What are Python generators?"})
|
||||
```
|
||||
|
||||
### Multi-document agents
|
||||
|
||||
```python
|
||||
# Multiple knowledge bases
|
||||
python_retriever = create_retriever_tool(
|
||||
retriever=python_vectorstore.as_retriever(),
|
||||
name="python_docs",
|
||||
description="Python programming documentation"
|
||||
)
|
||||
|
||||
numpy_retriever = create_retriever_tool(
|
||||
retriever=numpy_vectorstore.as_retriever(),
|
||||
name="numpy_docs",
|
||||
description="NumPy library documentation"
|
||||
)
|
||||
|
||||
# Agent chooses which knowledge base to query
|
||||
agent = create_agent(
|
||||
model=llm,
|
||||
tools=[python_retriever, numpy_retriever, search]
|
||||
)
|
||||
|
||||
result = agent.invoke({"input": "How do I create numpy arrays?"})
|
||||
```
|
||||
|
||||
## Metadata filtering
|
||||
|
||||
### Add metadata to documents
|
||||
|
||||
```python
|
||||
from langchain.schema import Document
|
||||
|
||||
# Documents with metadata
|
||||
docs = [
|
||||
Document(
|
||||
page_content="Python is a programming language",
|
||||
metadata={"source": "tutorial.pdf", "page": 1, "category": "intro"}
|
||||
),
|
||||
Document(
|
||||
page_content="Python decorators modify functions",
|
||||
metadata={"source": "advanced.pdf", "page": 42, "category": "advanced"}
|
||||
)
|
||||
]
|
||||
|
||||
vectorstore = Chroma.from_documents(docs, embeddings)
|
||||
```
|
||||
|
||||
### Filter by metadata
|
||||
|
||||
```python
|
||||
# Retrieve only from specific source
|
||||
retriever = vectorstore.as_retriever(
|
||||
search_kwargs={
|
||||
"k": 4,
|
||||
"filter": {"category": "intro"} # Only intro documents
|
||||
}
|
||||
)
|
||||
|
||||
# Multiple filters
|
||||
retriever = vectorstore.as_retriever(
|
||||
search_kwargs={
|
||||
"k": 4,
|
||||
"filter": {
|
||||
"category": "advanced",
|
||||
"source": "advanced.pdf"
|
||||
}
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Document preprocessing
|
||||
|
||||
### Clean documents
|
||||
|
||||
```python
|
||||
def preprocess_doc(doc):
|
||||
"""Clean and normalize document."""
|
||||
# Remove extra whitespace
|
||||
doc.page_content = " ".join(doc.page_content.split())
|
||||
|
||||
# Remove special characters
|
||||
doc.page_content = re.sub(r'[^\w\s]', '', doc.page_content)
|
||||
|
||||
# Lowercase (optional)
|
||||
doc.page_content = doc.page_content.lower()
|
||||
|
||||
return doc
|
||||
|
||||
# Apply preprocessing
|
||||
clean_docs = [preprocess_doc(doc) for doc in docs]
|
||||
```
|
||||
|
||||
### Extract structured data
|
||||
|
||||
```python
|
||||
from langchain.document_transformers import Html2TextTransformer
|
||||
|
||||
# HTML to clean text
|
||||
transformer = Html2TextTransformer()
|
||||
clean_docs = transformer.transform_documents(html_docs)
|
||||
|
||||
# Extract tables
|
||||
from langchain.document_loaders import UnstructuredHTMLLoader
|
||||
|
||||
loader = UnstructuredHTMLLoader("data.html")
|
||||
docs = loader.load() # Extracts tables as structured data
|
||||
```
|
||||
|
||||
## Evaluation & monitoring
|
||||
|
||||
### Evaluate retrieval quality
|
||||
|
||||
```python
|
||||
from langchain.evaluation import load_evaluator
|
||||
|
||||
# Relevance evaluator
|
||||
evaluator = load_evaluator("relevance", llm=llm)
|
||||
|
||||
# Test retrieval
|
||||
query = "What are Python decorators?"
|
||||
retrieved_docs = retriever.get_relevant_documents(query)
|
||||
|
||||
for doc in retrieved_docs:
|
||||
result = evaluator.evaluate_strings(
|
||||
input=query,
|
||||
prediction=doc.page_content
|
||||
)
|
||||
print(f"Relevance score: {result['score']}")
|
||||
```
|
||||
|
||||
### Track sources
|
||||
|
||||
```python
|
||||
# Always return sources
|
||||
qa_chain = RetrievalQA.from_chain_type(
|
||||
llm=llm,
|
||||
retriever=retriever,
|
||||
return_source_documents=True
|
||||
)
|
||||
|
||||
result = qa_chain({"query": "What is Python?"})
|
||||
|
||||
# Show sources to user
|
||||
print(result["result"])
|
||||
print("\nSources:")
|
||||
for i, doc in enumerate(result["source_documents"]):
|
||||
print(f"[{i+1}] {doc.metadata.get('source', 'Unknown')}")
|
||||
print(f" {doc.page_content[:100]}...")
|
||||
```
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Chunk size matters** - 512-1024 tokens is usually optimal
|
||||
2. **Add overlap** - 10-20% overlap prevents context loss
|
||||
3. **Use metadata** - Track sources for citations
|
||||
4. **Test retrieval quality** - Evaluate before using in production
|
||||
5. **Hybrid search** - Combine vector + keyword for best results
|
||||
6. **Compress context** - Remove irrelevant parts before LLM
|
||||
7. **Cache embeddings** - Expensive, cache when possible
|
||||
8. **Version your index** - Track changes to knowledge base
|
||||
9. **Monitor failures** - Log when retrieval doesn't find answers
|
||||
10. **Update regularly** - Keep knowledge base current
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
1. **Chunks too large** - Won't fit in context
|
||||
2. **No overlap** - Important context lost at boundaries
|
||||
3. **No metadata** - Can't cite sources
|
||||
4. **Poor splitting** - Breaks mid-sentence or mid-paragraph
|
||||
5. **Wrong embedding model** - Domain mismatch hurts retrieval
|
||||
6. **No reranking** - Lower quality results
|
||||
7. **Ignoring failures** - No handling when retrieval fails
|
||||
|
||||
## Performance optimization
|
||||
|
||||
### Caching
|
||||
|
||||
```python
|
||||
from langchain.cache import InMemoryCache, SQLiteCache
|
||||
from langchain.globals import set_llm_cache
|
||||
|
||||
# In-memory cache
|
||||
set_llm_cache(InMemoryCache())
|
||||
|
||||
# Persistent cache
|
||||
set_llm_cache(SQLiteCache(database_path=".langchain.db"))
|
||||
|
||||
# Same query uses cache (faster + cheaper)
|
||||
result1 = qa_chain({"query": "What is Python?"})
|
||||
result2 = qa_chain({"query": "What is Python?"}) # Cached
|
||||
```
|
||||
|
||||
### Batch processing
|
||||
|
||||
```python
|
||||
# Process multiple queries efficiently
|
||||
queries = [
|
||||
"What is Python?",
|
||||
"What are decorators?",
|
||||
"How do I use async?"
|
||||
]
|
||||
|
||||
# Batch retrieval
|
||||
all_docs = vectorstore.similarity_search_batch(queries)
|
||||
|
||||
# Batch QA
|
||||
results = qa_chain.batch([{"query": q} for q in queries])
|
||||
```
|
||||
|
||||
### Async operations
|
||||
|
||||
```python
|
||||
# Async RAG for concurrent queries
|
||||
import asyncio
|
||||
|
||||
async def async_qa(query):
|
||||
return await qa_chain.ainvoke({"query": query})
|
||||
|
||||
# Run multiple queries concurrently
|
||||
results = await asyncio.gather(
|
||||
async_qa("What is Python?"),
|
||||
async_qa("What are decorators?")
|
||||
)
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **LangChain RAG Docs**: https://docs.langchain.com/oss/python/langchain/rag
|
||||
- **Vector Stores**: https://python.langchain.com/docs/integrations/vectorstores
|
||||
- **Document Loaders**: https://python.langchain.com/docs/integrations/document_loaders
|
||||
- **Retrievers**: https://python.langchain.com/docs/modules/data_connection/retrievers
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url)
|
||||
const code = searchParams.get('code')
|
||||
const next = searchParams.get('next') ?? '/'
|
||||
|
||||
if (code) {
|
||||
const supabase = await createClient()
|
||||
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
||||
|
||||
if (!error) {
|
||||
// Check if user has completed onboarding
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (user) {
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings?.onboarding_complete) {
|
||||
return NextResponse.redirect(`${origin}/onboarding`)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${origin}${next}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the user to an error page with instructions
|
||||
return NextResponse.redirect(`${origin}/login?error=auth_error`)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2, Mail, Sparkles } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isEmailSent, setIsEmailSent] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
email,
|
||||
options: {
|
||||
emailRedirectTo: `${process.env.NEXT_PUBLIC_APP_URL || window.location.origin}/auth/callback`,
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsEmailSent(true)
|
||||
toast({
|
||||
title: 'E-post skickad!',
|
||||
description: 'Kolla din inkorg för att logga in.',
|
||||
})
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Något gick fel. Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmailSent) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Mail className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Kolla din e-post</CardTitle>
|
||||
<CardDescription>
|
||||
Vi har skickat en inloggningslänk till <strong>{email}</strong>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Klicka på länken i e-posten för att logga in. Länken är giltig i 1 timme.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setIsEmailSent(false)}
|
||||
>
|
||||
Använd en annan e-post
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary/5 via-background to-primary/10 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Influencer Assistant</CardTitle>
|
||||
<CardDescription>
|
||||
Logga in med din e-post för att hantera din verksamhet
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-postadress</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="namn@exempel.se"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !email}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skickar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Skicka inloggningslänk
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Genom att logga in godkänner du våra{' '}
|
||||
<a href="#" className="underline hover:text-primary">
|
||||
villkor
|
||||
</a>{' '}
|
||||
och{' '}
|
||||
<a href="#" className="underline hover:text-primary">
|
||||
integritetspolicy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
TikTokConnectButton,
|
||||
TikTokAccountCard,
|
||||
TikTokStatsWidget,
|
||||
TikTokGrowthChart,
|
||||
TikTokVideoList,
|
||||
VideoLinkModal,
|
||||
TikTokROITable,
|
||||
} from '@/components/tiktok'
|
||||
import type { TikTokAccount, TikTokStatsSummary, TikTokVideo, TikTokCampaignROI } from '@/types'
|
||||
import {
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
Video,
|
||||
Target,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [accounts, setAccounts] = useState<TikTokAccount[]>([])
|
||||
const [stats, setStats] = useState<TikTokStatsSummary | null>(null)
|
||||
const [roiData, setRoiData] = useState<TikTokCampaignROI[]>([])
|
||||
const [selectedVideo, setSelectedVideo] = useState<TikTokVideo | null>(null)
|
||||
const [isLinkModalOpen, setIsLinkModalOpen] = useState(false)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [])
|
||||
|
||||
const checkAuth = async () => {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
await Promise.all([
|
||||
fetchAccounts(),
|
||||
fetchStats(),
|
||||
fetchROI(),
|
||||
])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tiktok/accounts')
|
||||
const data = await response.json()
|
||||
setAccounts(data.accounts || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch accounts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/tiktok/stats')
|
||||
const data = await response.json()
|
||||
setStats(data.summary || null)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchROI = async () => {
|
||||
// Fetch campaigns with TikTok videos for ROI calculation
|
||||
try {
|
||||
const response = await fetch('/api/tiktok/videos?limit=100')
|
||||
const data = await response.json()
|
||||
|
||||
// Group videos by campaign and calculate ROI
|
||||
// This is a simplified version - the actual calculation is in the API
|
||||
const campaignVideos = new Map<string, TikTokVideo[]>()
|
||||
for (const video of data.videos || []) {
|
||||
if (video.campaign_id) {
|
||||
if (!campaignVideos.has(video.campaign_id)) {
|
||||
campaignVideos.set(video.campaign_id, [])
|
||||
}
|
||||
campaignVideos.get(video.campaign_id)!.push(video)
|
||||
}
|
||||
}
|
||||
|
||||
// For now, just set empty - actual ROI data would come from a dedicated endpoint
|
||||
setRoiData([])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ROI data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
if (accounts.length === 0) return
|
||||
|
||||
setIsSyncing(true)
|
||||
try {
|
||||
const activeAccount = accounts.find(a => a.status === 'active')
|
||||
if (activeAccount) {
|
||||
await fetch('/api/tiktok/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_id: activeAccount.id, sync_type: 'full' }),
|
||||
})
|
||||
await fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync failed:', error)
|
||||
}
|
||||
setIsSyncing(false)
|
||||
}
|
||||
|
||||
const handleVideoLinkClick = (video: TikTokVideo) => {
|
||||
setSelectedVideo(video)
|
||||
setIsLinkModalOpen(true)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeAccount = accounts.find(a => a.status === 'active')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Analysera din sociala medieprestanda och kampanj-ROI
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No connected account */}
|
||||
{accounts.length === 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Koppla TikTok</CardTitle>
|
||||
<CardDescription>
|
||||
Anslut ditt TikTok-konto för att se statistik och analysera kampanjprestanda
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokConnectButton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connected account */}
|
||||
{activeAccount && (
|
||||
<>
|
||||
{/* Stats overview */}
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Följare</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{stats?.currentFollowers.toLocaleString('sv-SE') || '0'}
|
||||
</p>
|
||||
{stats?.followerChange7d !== undefined && (
|
||||
<p className={`text-sm ${stats.followerChange7d >= 0 ? 'text-success' : 'text-destructive'}`}>
|
||||
{stats.followerChange7d >= 0 ? '+' : ''}{stats.followerChange7d.toLocaleString('sv-SE')} senaste 7 dagar
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Videor</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{stats?.totalVideos || 0}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{stats?.totalLikes.toLocaleString('sv-SE') || '0'} totala likes
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Engagement Rate</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{stats?.engagementRate.toFixed(1) || '0'}%
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Genomsnitt senaste videor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">30-dagars tillväxt</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-2">
|
||||
{stats?.followerChange30d !== undefined ? (
|
||||
<>
|
||||
{stats.followerChange30d >= 0 ? '+' : ''}
|
||||
{stats.followerChange30d.toLocaleString('sv-SE')}
|
||||
</>
|
||||
) : '0'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
nya följare
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs for different views */}
|
||||
<Tabs defaultValue="growth" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="growth">Tillväxt</TabsTrigger>
|
||||
<TabsTrigger value="videos">Videor</TabsTrigger>
|
||||
<TabsTrigger value="roi">Kampanj-ROI</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="growth" className="space-y-6">
|
||||
<TikTokGrowthChart accountId={activeAccount.id} />
|
||||
|
||||
{/* Recent videos with metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Senaste videor</CardTitle>
|
||||
<CardDescription>
|
||||
Prestanda för dina senaste publiceringar
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokVideoList
|
||||
accountId={activeAccount.id}
|
||||
limit={6}
|
||||
onLinkClick={handleVideoLinkClick}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="videos" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Alla videor</CardTitle>
|
||||
<CardDescription>
|
||||
Klicka på länk-ikonen för att koppla en video till en kampanj
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokVideoList
|
||||
accountId={activeAccount.id}
|
||||
limit={20}
|
||||
onLinkClick={handleVideoLinkClick}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roi" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kampanj-ROI</CardTitle>
|
||||
<CardDescription>
|
||||
Analysera avkastningen på dina influencer-kampanjer baserat på TikTok-prestanda
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokROITable campaigns={roiData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Account info at bottom */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kopplat konto</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokAccountCard
|
||||
account={activeAccount}
|
||||
onDisconnect={fetchData}
|
||||
onSync={fetchData}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Video link modal */}
|
||||
<VideoLinkModal
|
||||
video={selectedVideo}
|
||||
isOpen={isLinkModalOpen}
|
||||
onClose={() => {
|
||||
setIsLinkModalOpen(false)
|
||||
setSelectedVideo(null)
|
||||
}}
|
||||
onSuccess={fetchData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
|
||||
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
|
||||
import ChartOfAccounts from '@/components/bookkeeping/ChartOfAccounts'
|
||||
|
||||
export default function BookkeepingPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bokföring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Verifikationer, kontoplan och manuella bokföringsorder
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="journal">
|
||||
<TabsList>
|
||||
<TabsTrigger value="journal">Verifikationer</TabsTrigger>
|
||||
<TabsTrigger value="new-entry">Ny verifikation</TabsTrigger>
|
||||
<TabsTrigger value="accounts">Kontoplan</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="journal">
|
||||
<JournalEntryList key={refreshKey} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-entry">
|
||||
<JournalEntryForm onCreated={() => setRefreshKey((k) => k + 1)} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accounts">
|
||||
<ChartOfAccounts />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { PaymentCalendar } from '@/components/calendar/PaymentCalendar'
|
||||
import type { Invoice, Deadline } from '@/types'
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [deadlines, setDeadlines] = useState<Deadline[]>([])
|
||||
const [customers, setCustomers] = useState<{ id: string; name: string }[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Fetch invoices with customer names
|
||||
const { data: invoicesData, error: invoicesError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (invoicesError) throw invoicesError
|
||||
|
||||
// Fetch deadlines with customer names
|
||||
const { data: deadlinesData, error: deadlinesError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(name)')
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (deadlinesError) throw deadlinesError
|
||||
|
||||
// Fetch customers for the form
|
||||
const { data: customersData, error: customersError } = await supabase
|
||||
.from('customers')
|
||||
.select('id, name')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (customersError) throw customersError
|
||||
|
||||
setInvoices(invoicesData || [])
|
||||
setDeadlines(deadlinesData || [])
|
||||
setCustomers(customersData || [])
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [supabase, toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const handleDeadlineCreate = async (
|
||||
data: Omit<Deadline, 'id' | 'user_id' | 'created_at' | 'updated_at'>
|
||||
) => {
|
||||
try {
|
||||
const { error } = await supabase.from('deadlines').insert([data])
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast({
|
||||
title: 'Deadline skapad',
|
||||
description: 'Din deadline har sparats',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte skapa deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeadlineToggle = async (deadline: Deadline) => {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: !deadline.is_completed,
|
||||
completed_at: !deadline.is_completed ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq('id', deadline.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast({
|
||||
title: deadline.is_completed ? 'Markerad som ej klar' : 'Markerad som klar',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera deadline',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
||||
</div>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-10 bg-muted rounded w-48 mb-4" />
|
||||
<div className="h-96 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kalender</h1>
|
||||
</div>
|
||||
|
||||
<PaymentCalendar
|
||||
invoices={invoices}
|
||||
deadlines={deadlines}
|
||||
customers={customers}
|
||||
onDeadlineCreate={handleDeadlineCreate}
|
||||
onDeadlineToggle={handleDeadlineToggle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Campaign, Customer } from '@/types'
|
||||
import { CampaignDetail, CampaignForm } from '@/components/campaigns'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default function CampaignDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
const supabase = createClient()
|
||||
const { toast } = useToast()
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editFormOpen, setEditFormOpen] = useState(false)
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/campaigns/${id}`)
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
setCampaign(data)
|
||||
} else {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Samarbetet hittades inte',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ladda samarbetet',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCustomers = async () => {
|
||||
const { data } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('name')
|
||||
setCustomers(data || [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaign()
|
||||
fetchCustomers()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Samarbetet hittades inte</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CampaignDetail
|
||||
campaign={campaign}
|
||||
onUpdate={fetchCampaign}
|
||||
onEdit={() => setEditFormOpen(true)}
|
||||
/>
|
||||
|
||||
<CampaignForm
|
||||
open={editFormOpen}
|
||||
onOpenChange={setEditFormOpen}
|
||||
initialData={campaign}
|
||||
customers={customers}
|
||||
onSuccess={() => {
|
||||
setEditFormOpen(false)
|
||||
fetchCampaign()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { ContractImportWizard } from '@/components/contracts/ContractImportWizard'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Importera avtal | Samarbeten',
|
||||
description: 'Importera och analysera avtal med AI',
|
||||
}
|
||||
|
||||
export default async function CampaignImportPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Fetch customers for matching
|
||||
const { data: customers } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('name')
|
||||
|
||||
return (
|
||||
<div className="container max-w-6xl py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Importera avtal</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Ladda upp ett avtal och låt AI extrahera samarbetsinformation automatiskt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContractImportWizard customers={customers || []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Customer, CreateCampaignInput, CampaignType, BillingFrequency } from '@/types'
|
||||
import {
|
||||
CAMPAIGN_TYPE_LABELS,
|
||||
BILLING_FREQUENCY_LABELS,
|
||||
} from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
const CURRENCIES = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { toast } = useToast()
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState<CreateCampaignInput>({
|
||||
name: '',
|
||||
description: '',
|
||||
customer_id: '',
|
||||
brand_name: '',
|
||||
campaign_type: 'influencer',
|
||||
total_value: undefined,
|
||||
currency: 'SEK',
|
||||
vat_included: false,
|
||||
payment_terms: 30,
|
||||
billing_frequency: undefined,
|
||||
publication_date: '',
|
||||
draft_deadline: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCustomers = async () => {
|
||||
const { data } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('name')
|
||||
setCustomers(data || [])
|
||||
}
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.name) {
|
||||
toast({ title: 'Namn krävs', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/campaigns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
customer_id: formData.customer_id || null,
|
||||
brand_name: formData.brand_name || null,
|
||||
total_value: formData.total_value || null,
|
||||
payment_terms: formData.payment_terms || null,
|
||||
billing_frequency: formData.billing_frequency || null,
|
||||
publication_date: formData.publication_date || null,
|
||||
draft_deadline: formData.draft_deadline || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error || 'Failed to create campaign')
|
||||
}
|
||||
|
||||
const { data } = await response.json()
|
||||
|
||||
toast({
|
||||
title: 'Samarbete skapat',
|
||||
description: formData.name,
|
||||
})
|
||||
|
||||
router.push(`/campaigns/${data.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/campaigns"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Tillbaka till samarbeten
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Nytt samarbete</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Skapa ett nytt samarbete för att spåra innehåll, avtal och betalningar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Grundläggande information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Namn på samarbete *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="T.ex. Sommarkampanj 2025"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Beskrivning</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Kort beskrivning av kampanjen..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="brand_name">Varumärke</Label>
|
||||
<Input
|
||||
id="brand_name"
|
||||
value={formData.brand_name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, brand_name: e.target.value })}
|
||||
placeholder="T.ex. Nike, Adidas..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="campaign_type">Typ</Label>
|
||||
<Select
|
||||
value={formData.campaign_type}
|
||||
onValueChange={(v) => setFormData({ ...formData, campaign_type: v as CampaignType })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(CAMPAIGN_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="customer_id">Byrå / Uppdragsgivare</Label>
|
||||
<Select
|
||||
value={formData.customer_id || ''}
|
||||
onValueChange={(v) => setFormData({
|
||||
...formData,
|
||||
customer_id: v,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kund" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Ekonomi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="total_value">Totalvärde</Label>
|
||||
<Input
|
||||
id="total_value"
|
||||
type="number"
|
||||
value={formData.total_value || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
total_value: e.target.value ? parseFloat(e.target.value) : undefined
|
||||
})}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="currency">Valuta</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => setFormData({ ...formData, currency: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<Switch
|
||||
id="vat_included"
|
||||
checked={formData.vat_included}
|
||||
onCheckedChange={(v) => setFormData({ ...formData, vat_included: v })}
|
||||
/>
|
||||
<Label htmlFor="vat_included" className="font-normal">Inkl. moms</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="payment_terms"
|
||||
type="number"
|
||||
value={formData.payment_terms || ''}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
payment_terms: e.target.value ? parseInt(e.target.value) : undefined
|
||||
})}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="billing_frequency">Faktureringsmodell</Label>
|
||||
<Select
|
||||
value={formData.billing_frequency || ''}
|
||||
onValueChange={(v) => setFormData({
|
||||
...formData,
|
||||
billing_frequency: v as BillingFrequency || undefined
|
||||
})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(BILLING_FREQUENCY_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Datum</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="publication_date">Publiceringsdatum</Label>
|
||||
<Input
|
||||
id="publication_date"
|
||||
type="date"
|
||||
value={formData.publication_date || ''}
|
||||
onChange={(e) => setFormData({ ...formData, publication_date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="draft_deadline">Utkastdeadline</Label>
|
||||
<Input
|
||||
id="draft_deadline"
|
||||
type="date"
|
||||
value={formData.draft_deadline || ''}
|
||||
onChange={(e) => setFormData({ ...formData, draft_deadline: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={formData.notes || ''}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
placeholder="Interna anteckningar..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Link href="/campaigns">
|
||||
<Button type="button" variant="outline">Avbryt</Button>
|
||||
</Link>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? 'Skapar...' : 'Skapa samarbete'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Campaign, Customer } from '@/types'
|
||||
import { CampaignList, CampaignForm } from '@/components/campaigns'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, FileUp } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const supabase = createClient()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([])
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch campaigns
|
||||
const campaignsResponse = await fetch('/api/campaigns')
|
||||
if (campaignsResponse.ok) {
|
||||
const { data } = await campaignsResponse.json()
|
||||
setCampaigns(data || [])
|
||||
}
|
||||
|
||||
// Fetch customers for the form
|
||||
const { data: customersData } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('name')
|
||||
|
||||
setCustomers(customersData || [])
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ladda data',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Samarbeten"
|
||||
description="Hantera dina samarbeten, innehåll och avtal"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Skapa samarbete
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push('/campaigns/import')}>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Importera avtal
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<CampaignList campaigns={campaigns} loading={loading} />
|
||||
|
||||
<CampaignForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
customers={customers}
|
||||
onSuccess={() => {
|
||||
setFormOpen(false)
|
||||
fetchData()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { use } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { CampaignStatusBadge } from '@/components/campaigns/CampaignStatusBadge'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building,
|
||||
Globe,
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Edit2,
|
||||
Trash2,
|
||||
FileText,
|
||||
Loader2,
|
||||
Receipt,
|
||||
Briefcase,
|
||||
} from 'lucide-react'
|
||||
import type { Customer, CustomerType, CreateCustomerInput, CampaignStatus } from '@/types'
|
||||
|
||||
const customerTypeLabels: Record<CustomerType, string> = {
|
||||
individual: 'Privatperson',
|
||||
swedish_business: 'Svenskt foretag',
|
||||
eu_business: 'EU-foretag',
|
||||
non_eu_business: 'Utanfor EU',
|
||||
}
|
||||
|
||||
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
|
||||
individual: User,
|
||||
swedish_business: Building,
|
||||
eu_business: Globe,
|
||||
non_eu_business: Globe,
|
||||
}
|
||||
|
||||
interface RelatedCampaign {
|
||||
id: string
|
||||
name: string
|
||||
status: CampaignStatus
|
||||
total_value: number | null
|
||||
currency: string | null
|
||||
publication_date: string | null
|
||||
brand_name: string | null
|
||||
}
|
||||
|
||||
interface RelatedInvoice {
|
||||
id: string
|
||||
invoice_number: string
|
||||
invoice_date: string
|
||||
due_date: string
|
||||
status: string
|
||||
total: number
|
||||
currency: string
|
||||
payment_status: string
|
||||
}
|
||||
|
||||
interface CustomerWithRelations extends Customer {
|
||||
campaigns: RelatedCampaign[]
|
||||
invoices: RelatedInvoice[]
|
||||
}
|
||||
|
||||
export default function CustomerDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [customer, setCustomer] = useState<CustomerWithRelations | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomer()
|
||||
}, [id])
|
||||
|
||||
async function fetchCustomer() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Not found')
|
||||
}
|
||||
const { data } = await response.json()
|
||||
setCustomer(data)
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hitta kunden',
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.push('/customers')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(data: CreateCustomerInput) {
|
||||
setIsUpdating(true)
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Update failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Kund uppdaterad',
|
||||
description: data.name,
|
||||
})
|
||||
setIsEditOpen(false)
|
||||
fetchCustomer()
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera kunden',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!customer) return
|
||||
if (!confirm(`Ta bort "${customer.name}"? Detta kan inte angras.`)) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/customers/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Delete failed')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Kund borttagen',
|
||||
description: customer.name,
|
||||
})
|
||||
router.push('/customers')
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort kunden',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number | null, currency: string | null) => {
|
||||
if (!amount) return '-'
|
||||
return new Intl.NumberFormat('sv-SE', {
|
||||
style: 'currency',
|
||||
currency: currency || 'SEK',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!customer) return null
|
||||
|
||||
const Icon = customerTypeIcons[customer.customer_type]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<Link
|
||||
href="/customers"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Tillbaka till kunder
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{customer.name}</h1>
|
||||
<Badge variant="secondary">{customerTypeLabels[customer.customer_type]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsEditOpen(true)}>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Redigera
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Ta bort
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Contact */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Kontaktuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<a href={`mailto:${customer.email}`} className="hover:underline">
|
||||
{customer.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{customer.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
{customer.phone}
|
||||
</div>
|
||||
)}
|
||||
{(customer.address_line1 || customer.city) && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
||||
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
||||
{(customer.postal_code || customer.city) && (
|
||||
<p>{[customer.postal_code, customer.city].filter(Boolean).join(' ')}</p>
|
||||
)}
|
||||
{customer.country && <p>{customer.country}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!customer.email && !customer.phone && !customer.address_line1 && !customer.city && (
|
||||
<p className="text-sm text-muted-foreground">Inga kontaktuppgifter</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Business details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Foretagsuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{customer.org_number && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Org.nr: </span>
|
||||
{customer.org_number}
|
||||
</div>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
<div className="text-sm flex items-center gap-2">
|
||||
<span className="text-muted-foreground">VAT: </span>
|
||||
{customer.vat_number}
|
||||
{customer.vat_number_validated && (
|
||||
<Badge variant="success" className="text-xs">Verifierad</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Betalningsvillkor: </span>
|
||||
{customer.default_payment_terms || 30} dagar
|
||||
</div>
|
||||
{!customer.org_number && !customer.vat_number && (
|
||||
<p className="text-sm text-muted-foreground">Inga foretagsuppgifter</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Oversikt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{customer.campaigns?.length || 0} samarbeten</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{customer.invoices?.length || 0} fakturor</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{customer.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anteckningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{customer.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Related campaigns */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
Samarbeten
|
||||
{customer.campaigns?.length > 0 && (
|
||||
<Badge variant="secondary">{customer.campaigns.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{customer.campaigns?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{customer.campaigns.map((campaign) => (
|
||||
<Link
|
||||
key={campaign.id}
|
||||
href={`/campaigns/${campaign.id}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{campaign.name}</p>
|
||||
{campaign.brand_name && (
|
||||
<p className="text-sm text-muted-foreground">{campaign.brand_name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm tabular-nums">
|
||||
{formatCurrency(campaign.total_value, campaign.currency)}
|
||||
</span>
|
||||
<CampaignStatusBadge status={campaign.status} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Inga samarbeten kopplade till denna kund
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Related invoices */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Receipt className="h-4 w-4" />
|
||||
Fakturor
|
||||
{customer.invoices?.length > 0 && (
|
||||
<Badge variant="secondary">{customer.invoices.length}</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{customer.invoices?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{customer.invoices.map((invoice) => (
|
||||
<Link
|
||||
key={invoice.id}
|
||||
href={`/invoices/${invoice.id}`}
|
||||
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{invoice.invoice_number}</p>
|
||||
<p className="text-sm text-muted-foreground">{invoice.invoice_date}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm tabular-nums">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</span>
|
||||
<Badge variant={invoice.payment_status === 'paid' ? 'success' : 'secondary'}>
|
||||
{invoice.payment_status === 'paid' ? 'Betald' : invoice.payment_status === 'overdue' ? 'Forsenad' : 'Obestallt'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Inga fakturor kopplade till denna kund
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Redigera kund</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomerForm
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={isUpdating}
|
||||
initialData={{
|
||||
name: customer.name,
|
||||
customer_type: customer.customer_type,
|
||||
email: customer.email || undefined,
|
||||
phone: customer.phone || undefined,
|
||||
address_line1: customer.address_line1 || undefined,
|
||||
address_line2: customer.address_line2 || undefined,
|
||||
postal_code: customer.postal_code || undefined,
|
||||
city: customer.city || undefined,
|
||||
country: customer.country || undefined,
|
||||
org_number: customer.org_number || undefined,
|
||||
vat_number: customer.vat_number || undefined,
|
||||
default_payment_terms: customer.default_payment_terms || undefined,
|
||||
notes: customer.notes || undefined,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Search, Users, Building, Globe, User } from 'lucide-react'
|
||||
import CustomerForm from '@/components/customers/CustomerForm'
|
||||
import { EmptyCustomers } from '@/components/ui/empty-state'
|
||||
import Link from 'next/link'
|
||||
import type { Customer, CustomerType, CreateCustomerInput } from '@/types'
|
||||
|
||||
const customerTypeLabels: Record<CustomerType, string> = {
|
||||
individual: 'Privatperson',
|
||||
swedish_business: 'Svenskt företag',
|
||||
eu_business: 'EU-företag',
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
const customerTypeIcons: Record<CustomerType, React.ElementType> = {
|
||||
individual: User,
|
||||
swedish_business: Building,
|
||||
eu_business: Globe,
|
||||
non_eu_business: Globe,
|
||||
}
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
async function fetchCustomers() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta kunder',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setCustomers(data || [])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleCreateCustomer(data: CreateCustomerInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
const response = await fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error || 'Kunde inte skapa kund',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Kund skapad',
|
||||
description: `${data.name} har lagts till`,
|
||||
})
|
||||
setCustomers([...customers, result.data])
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
const filteredCustomers = customers.filter((customer) =>
|
||||
customer.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
customer.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
customer.org_number?.includes(searchTerm)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Kunder</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina kunder och deras faktureringsuppgifter
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny kund
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lägg till kund</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomerForm
|
||||
onSubmit={handleCreateCustomer}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på namn, e-post eller org.nr..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer list */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-5 bg-muted rounded w-1/2" />
|
||||
<div className="h-4 bg-muted rounded w-1/3 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Users className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga kunder matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyCustomers />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCustomers.map((customer) => {
|
||||
const Icon = customerTypeIcons[customer.customer_type]
|
||||
return (
|
||||
<Link key={customer.id} href={`/customers/${customer.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{customer.name}</CardTitle>
|
||||
<CardDescription>{customer.email || 'Ingen e-post'}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{customerTypeLabels[customer.customer_type]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{customer.org_number && (
|
||||
<p>Org.nr: {customer.org_number}</p>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
<p className="flex items-center gap-1">
|
||||
VAT: {customer.vat_number}
|
||||
{customer.vat_number_validated && (
|
||||
<Badge variant="success" className="text-xs">Verifierad</Badge>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{customer.city && (
|
||||
<p>{customer.city}, {customer.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
getSchablonavdragSummary,
|
||||
groupMileageEntriesByMonth,
|
||||
formatMonthKey,
|
||||
SCHABLONAVDRAG_RATES,
|
||||
} from '@/lib/tax/schablonavdrag'
|
||||
import SchablonavdragSettings from '@/components/settings/SchablonavdragSettings'
|
||||
import MileageEntry from '@/components/transactions/MileageEntry'
|
||||
import {
|
||||
Loader2,
|
||||
Home,
|
||||
Car,
|
||||
Plus,
|
||||
Trash2,
|
||||
Calendar,
|
||||
MapPin,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
CompanySettings,
|
||||
SchablonavdragSettings as SchablonavdragSettingsType,
|
||||
MileageEntry as MileageEntryType,
|
||||
CreateMileageEntryInput,
|
||||
} from '@/types'
|
||||
|
||||
export default function DeductionsPage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [mileageEntries, setMileageEntries] = useState<MileageEntryType[]>([])
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true)
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
setSettings(settingsData)
|
||||
|
||||
// Fetch mileage entries for current year
|
||||
const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
|
||||
const { data: entries } = await supabase
|
||||
.from('mileage_entries')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startOfYear)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
setMileageEntries(entries || [])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(newSettings: SchablonavdragSettingsType) {
|
||||
if (!settings) return
|
||||
|
||||
const { error } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ schablonavdrag_settings: newSettings })
|
||||
.eq('id', settings.id)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
setSettings({
|
||||
...settings,
|
||||
schablonavdrag_settings: newSettings,
|
||||
} as CompanySettings & { schablonavdrag_settings: SchablonavdragSettingsType })
|
||||
}
|
||||
|
||||
async function handleAddMileageEntry(entry: CreateMileageEntryInput) {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mileage_entries')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: entry.date,
|
||||
distance_km: entry.distance_km,
|
||||
purpose: entry.purpose,
|
||||
from_location: entry.from_location || null,
|
||||
to_location: entry.to_location || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
setMileageEntries([data, ...mileageEntries])
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
async function handleDeleteMileageEntry(id: string) {
|
||||
const { error } = await supabase.from('mileage_entries').delete().eq('id', id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte ta bort körningen',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setMileageEntries(mileageEntries.filter((e) => e.id !== id))
|
||||
toast({
|
||||
title: 'Borttaget',
|
||||
description: 'Körningen har tagits bort',
|
||||
})
|
||||
}
|
||||
|
||||
function toggleMonth(monthKey: string) {
|
||||
const newExpanded = new Set(expandedMonths)
|
||||
if (newExpanded.has(monthKey)) {
|
||||
newExpanded.delete(monthKey)
|
||||
} else {
|
||||
newExpanded.add(monthKey)
|
||||
}
|
||||
setExpandedMonths(newExpanded)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Get schablonavdrag settings
|
||||
const schablonavdragSettings = ((settings as CompanySettings & { schablonavdrag_settings?: SchablonavdragSettingsType })?.schablonavdrag_settings) || {
|
||||
hemmakontor_enabled: false,
|
||||
bil_enabled: false,
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const currentYear = new Date().getFullYear()
|
||||
const currentMonth = new Date().getMonth() + 1
|
||||
const summary = getSchablonavdragSummary(
|
||||
schablonavdragSettings,
|
||||
mileageEntries,
|
||||
currentYear,
|
||||
currentMonth
|
||||
)
|
||||
|
||||
// Group mileage entries by month
|
||||
const entriesByMonth = groupMileageEntriesByMonth(mileageEntries)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Schablonavdrag</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina schablonmässiga avdrag för hemmakontor och bilkostnader
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{/* Total deduction */}
|
||||
<Card className="bg-gradient-to-br from-success/10 via-success/5 to-background border-success/20">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Totalt avdrag {currentYear}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-success">
|
||||
{formatCurrency(summary.total_deduction)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Hemmakontor */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
Hemmakontor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{summary.hemmakontor.enabled ? formatCurrency(summary.hemmakontor.deduction) : '-'}
|
||||
</div>
|
||||
{summary.hemmakontor.enabled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.hemmakontor.months_active} månader
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bilkostnader */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Car className="h-4 w-4" />
|
||||
Bilkostnader
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{summary.mileage.enabled ? formatCurrency(summary.mileage.total_deduction) : '-'}
|
||||
</div>
|
||||
{summary.mileage.enabled && summary.mileage.total_km > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.mileage.total_km.toFixed(0)} km loggade
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="settings" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="settings">Inställningar</TabsTrigger>
|
||||
<TabsTrigger value="mileage" disabled={!schablonavdragSettings.bil_enabled}>
|
||||
Körjournal
|
||||
{schablonavdragSettings.bil_enabled && mileageEntries.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{mileageEntries.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Settings tab */}
|
||||
<TabsContent value="settings">
|
||||
<SchablonavdragSettings
|
||||
settings={schablonavdragSettings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Mileage log tab */}
|
||||
<TabsContent value="mileage" className="space-y-6">
|
||||
{/* Add new entry button/form */}
|
||||
{showAddForm ? (
|
||||
<MileageEntry onSave={handleAddMileageEntry} onCancel={() => setShowAddForm(false)} />
|
||||
) : (
|
||||
<Button onClick={() => setShowAddForm(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny körning
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Entries list grouped by month */}
|
||||
{mileageEntries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<Car className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium">Ingen körning loggad</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Börja logga dina tjänsteresor för att få milersättning
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(entriesByMonth.entries()).map(([monthKey, monthData]) => (
|
||||
<Card key={monthKey}>
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleMonth(monthKey)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{formatMonthKey(monthKey)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{monthData.entries.length} körningar · {monthData.totalKm.toFixed(0)} km
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-medium text-success">
|
||||
{formatCurrency(monthData.totalDeduction)}
|
||||
</span>
|
||||
{expandedMonths.has(monthKey) ? (
|
||||
<ChevronUp className="h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{expandedMonths.has(monthKey) && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{monthData.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="flex items-start justify-between p-3 rounded-lg border bg-muted/30"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{formatDate(entry.date)}</span>
|
||||
<Badge variant="secondary">
|
||||
{Number(entry.distance_km).toFixed(1)} km
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm">{entry.purpose}</p>
|
||||
{(entry.from_location || entry.to_location) && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{entry.from_location && entry.to_location
|
||||
? `${entry.from_location} → ${entry.to_location}`
|
||||
: entry.from_location || entry.to_location}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-success">
|
||||
{formatCurrency(Number(entry.total_deduction))}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDeleteMileageEntry(entry.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import GiftForm from '@/components/benefits/GiftForm'
|
||||
import GiftList from '@/components/benefits/GiftList'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { Plus, Gift, TrendingUp, CheckCircle, Receipt, AlertTriangle } from 'lucide-react'
|
||||
import type { Gift as GiftType, GiftSummary, CreateGiftInput, EntityType } from '@/types'
|
||||
|
||||
export default function GiftsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const [gifts, setGifts] = useState<GiftType[]>([])
|
||||
const [summary, setSummary] = useState<GiftSummary | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
|
||||
|
||||
const isLightMode = entityType === 'light'
|
||||
|
||||
// Dialog state
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [editingGift, setEditingGift] = useState<GiftType | null>(null)
|
||||
|
||||
// Year filter
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [selectedYear, setSelectedYear] = useState(
|
||||
searchParams.get('year') || currentYear.toString()
|
||||
)
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
||||
|
||||
// Fetch gifts and summary
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [giftsRes, summaryRes, settingsRes] = await Promise.all([
|
||||
fetch(`/api/gifts?year=${selectedYear}`),
|
||||
fetch(`/api/gifts/summary?year=${selectedYear}`),
|
||||
fetch('/api/settings'),
|
||||
])
|
||||
|
||||
if (giftsRes.ok) {
|
||||
const giftsData = await giftsRes.json()
|
||||
setGifts(giftsData.data || [])
|
||||
}
|
||||
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json()
|
||||
setSummary(summaryData.data || null)
|
||||
}
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const settingsData = await settingsRes.json()
|
||||
if (settingsData.data?.entity_type) {
|
||||
setEntityType(settingsData.data.entity_type as EntityType)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gifts:', error)
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta gåvor',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedYear, toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Update URL when year changes
|
||||
const handleYearChange = (year: string) => {
|
||||
setSelectedYear(year)
|
||||
router.push(`/gifts?year=${year}`)
|
||||
}
|
||||
|
||||
// Handle form submit (create or update)
|
||||
const handleSubmit = async (data: CreateGiftInput) => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const url = editingGift ? `/api/gifts/${editingGift.id}` : '/api/gifts'
|
||||
const method = editingGift ? 'PUT' : 'POST'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Unknown error')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: editingGift ? 'Gåva uppdaterad' : 'Gåva sparad',
|
||||
description: `${data.description} från ${data.brand_name}`,
|
||||
})
|
||||
|
||||
setIsFormOpen(false)
|
||||
setEditingGift(null)
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte spara gåva',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = (gift: GiftType) => {
|
||||
setEditingGift(gift)
|
||||
setIsFormOpen(true)
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (id: string) => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/gifts/${id}`, { method: 'DELETE' })
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Unknown error')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Gåva borttagen',
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte ta bort gåva',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dialog close
|
||||
const handleDialogClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsFormOpen(false)
|
||||
setEditingGift(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Gåvor & Förmåner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Logga produkter och gåvor du fått för korrekt skattehantering
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedYear} onValueChange={handleYearChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setIsFormOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny gåva
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-muted-foreground mb-1">
|
||||
<Gift className="h-4 w-4" />
|
||||
<span className="text-sm">Totalt antal</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{summary.total_count}</p>
|
||||
<p className="text-sm text-muted-foreground">{formatCurrency(summary.total_value)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-destructive mb-1">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="text-sm">Skattepliktig</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{summary.taxable_count}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(summary.taxable_value)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-success mb-1">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">Skattefria</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{summary.tax_free_count}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(summary.tax_free_value)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{isLightMode ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-warning mb-1">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm">Virtuell skatteskuld</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
{formatCurrency(summary.taxable_value * 0.32)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ca 32% av skattepliktiga gåvor
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-primary mb-1">
|
||||
<Receipt className="h-4 w-4" />
|
||||
<span className="text-sm">Avdragsgilla</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{summary.deductible_count}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(summary.deductible_value)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-4">
|
||||
<Gift className="h-8 w-8 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Varför logga gåvor?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Skatteverket granskar aktivt gåvor och förmåner som influencers får. Produkter du
|
||||
fått i utbyte mot att posta om dem är skattepliktiga. Genom att logga allt korrekt
|
||||
undviker du skattetillägg och kan dessutom göra avdrag för produkter som endast
|
||||
används i verksamheten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gift List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4">
|
||||
<Skeleton className="h-6 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<GiftList
|
||||
gifts={gifts}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form Dialog */}
|
||||
<Dialog open={isFormOpen} onOpenChange={handleDialogClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingGift ? 'Redigera gåva' : 'Lägg till ny gåva'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fyll i information om produkten eller gåvan du fått
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<GiftForm
|
||||
onSubmit={handleSubmit}
|
||||
initialData={
|
||||
editingGift
|
||||
? {
|
||||
date: editingGift.date,
|
||||
brand_name: editingGift.brand_name,
|
||||
description: editingGift.description,
|
||||
estimated_value: Number(editingGift.estimated_value),
|
||||
has_motprestation: editingGift.has_motprestation,
|
||||
used_in_business: editingGift.used_in_business,
|
||||
used_privately: editingGift.used_privately,
|
||||
is_simple_promo: editingGift.is_simple_promo,
|
||||
returned: editingGift.returned,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
isLoading={isSubmitting}
|
||||
isLightMode={isLightMode}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { HelpLink } from '@/components/ui/info-tooltip'
|
||||
import {
|
||||
Search,
|
||||
BookOpen,
|
||||
Receipt,
|
||||
Calculator,
|
||||
Building2,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface GlossaryTerm {
|
||||
term: string
|
||||
simpleTerm?: string // Vardagligt alternativ
|
||||
definition: string
|
||||
category: 'skatt' | 'moms' | 'faktura' | 'bokföring' | 'bank' | 'företag'
|
||||
skatteverketUrl?: string
|
||||
relatedTerms?: string[]
|
||||
}
|
||||
|
||||
const glossaryTerms: GlossaryTerm[] = [
|
||||
// Skatt
|
||||
{
|
||||
term: 'F-skatt',
|
||||
simpleTerm: 'Månatlig skatteinbetalning',
|
||||
definition:
|
||||
'F-skatt (företagsskatt) innebär att du som företagare själv ansvarar för att betala in preliminärskatt och egenavgifter. Du betalar in en fast summa varje månad baserat på din beräknade årsinkomst. Om du betalat för lite under året kan du få restskatt.',
|
||||
category: 'skatt',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/foretagarguiden/foretagsformer/enskildnaringsverksamhet/fskatt.4.361dc8c15312eff6fd1f8a3.html',
|
||||
relatedTerms: ['Preliminärskatt', 'Restskatt', 'Egenavgifter'],
|
||||
},
|
||||
{
|
||||
term: 'Preliminärskatt',
|
||||
definition:
|
||||
'Skatt som betalas in i förskott under inkomståret, baserat på uppskattad årsinkomst. Din F-skatteinbetalning är en form av preliminärskatt.',
|
||||
category: 'skatt',
|
||||
relatedTerms: ['F-skatt', 'Restskatt'],
|
||||
},
|
||||
{
|
||||
term: 'Egenavgifter',
|
||||
simpleTerm: 'Sociala avgifter',
|
||||
definition:
|
||||
'Som enskild näringsidkare betalar du egenavgifter (ca 28,97%) istället för arbetsgivaravgifter. Avgifterna finansierar socialförsäkringar som pension, sjukpenning och föräldrapenning - saker som anställda får via sin arbetsgivare.',
|
||||
category: 'skatt',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/foretagarguiden/avgifterochegenavgifter/egenavgifter.4.361dc8c15312eff6fd1e5e7.html',
|
||||
relatedTerms: ['Enskild firma'],
|
||||
},
|
||||
{
|
||||
term: 'Restskatt',
|
||||
definition:
|
||||
'Om du betalat in för lite preliminärskatt under året får du restskatt att betala. Det betyder att din faktiska skatt var högre än vad du betalade in via F-skatten.',
|
||||
category: 'skatt',
|
||||
relatedTerms: ['F-skatt', 'Preliminärskatt'],
|
||||
},
|
||||
{
|
||||
term: 'Schablonavdrag',
|
||||
simpleTerm: 'Enkla avdrag',
|
||||
definition:
|
||||
'Förenklade avdrag där du använder fasta belopp istället för att spara kvitton. Exempel: hemmakontor (2 000 kr/år) eller milersättning (25 kr/mil för bil). Perfekt om du inte vill krångla med att spara alla kvitton.',
|
||||
category: 'skatt',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/privat/skatter/arbeteochinkomst/avdrag.4.6efe6285127ab4f1d25800023187.html',
|
||||
relatedTerms: ['Avdrag', 'Hemmakontor'],
|
||||
},
|
||||
{
|
||||
term: 'NE-bilaga',
|
||||
definition:
|
||||
'En bilaga till din inkomstdeklaration där du redovisar resultatet från din enskilda näringsverksamhet. Appen hjälper dig samla underlaget - du behöver inte förstå alla detaljer.',
|
||||
category: 'skatt',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/privat/deklaration/blanketter/inkomstochfastighetsdeklaration/blankett21.4.6efe6285127ab4f1d25800023142.html',
|
||||
relatedTerms: ['Enskild firma', 'Inkomstdeklaration'],
|
||||
},
|
||||
{
|
||||
term: 'Disponibelt',
|
||||
simpleTerm: 'Ditt att spendera',
|
||||
definition:
|
||||
'Det belopp du kan använda fritt efter att vi räknat bort uppskattad skatt och moms från ditt saldo. Resten bör du "låsa" för framtida skatteinbetalningar.',
|
||||
category: 'skatt',
|
||||
},
|
||||
// Moms
|
||||
{
|
||||
term: 'Moms',
|
||||
simpleTerm: 'Mervärdesskatt',
|
||||
definition:
|
||||
'Mervärdesskatt som läggs på varor och tjänster. Som momsregistrerad lägger du på moms på dina fakturor och drar av moms på dina inköp. Skillnaden betalar eller får du tillbaka från Skatteverket.',
|
||||
category: 'moms',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/moms.4.65fc817e1077c25b8328000206.html',
|
||||
relatedTerms: ['Momsperiod', 'Ingående moms', 'Utgående moms'],
|
||||
},
|
||||
{
|
||||
term: 'Momsperiod',
|
||||
simpleTerm: 'Hur ofta du rapporterar moms',
|
||||
definition:
|
||||
'Hur ofta du redovisar och betalar moms till Skatteverket. Vanligast är kvartal (4 gånger/år). Osäker? Börja med kvartal - du kan ändra senare. Omsättning under 1 miljon = år möjlig, över 40 miljoner = månad krävs.',
|
||||
category: 'moms',
|
||||
relatedTerms: ['Moms', 'Momsdeklaration'],
|
||||
},
|
||||
{
|
||||
term: 'Omvänd skattskyldighet',
|
||||
simpleTerm: 'Kunden betalar momsen',
|
||||
definition:
|
||||
'När du säljer till företag i andra EU-länder betalar köparen momsen i sitt eget land. Du fakturerar 0% moms och skriver "Omvänd skattskyldighet" eller "Reverse charge" på fakturan.',
|
||||
category: 'moms',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/moms/saljavarortjanster/omvandskattskyldighetvidsaljandeinomeu.4.7be5268414bea0646940d0e.html',
|
||||
relatedTerms: ['EU-försäljning', 'Momsfri export'],
|
||||
},
|
||||
{
|
||||
term: 'Ingående moms',
|
||||
definition:
|
||||
'Moms du betalar på dina inköp (utgifter). Denna moms får du dra av från din momsredovisning.',
|
||||
category: 'moms',
|
||||
relatedTerms: ['Utgående moms', 'Moms'],
|
||||
},
|
||||
{
|
||||
term: 'Utgående moms',
|
||||
definition:
|
||||
'Moms du tar ut av dina kunder (lägger på fakturan). Denna moms ska du redovisa till Skatteverket.',
|
||||
category: 'moms',
|
||||
relatedTerms: ['Ingående moms', 'Moms'],
|
||||
},
|
||||
// Faktura
|
||||
{
|
||||
term: 'Förfallodag',
|
||||
definition:
|
||||
'Sista dag kunden ska betala fakturan. Vanligast är 30 dagar efter fakturadatum. Efter förfallodagen kan du skicka påminnelse och ta ut dröjsmålsränta.',
|
||||
category: 'faktura',
|
||||
relatedTerms: ['Dröjsmålsränta', 'Påminnelse'],
|
||||
},
|
||||
{
|
||||
term: 'OCR-nummer',
|
||||
definition:
|
||||
'Ett referensnummer som gör det enkelt att matcha inbetalningar med rätt faktura. Genereras automatiskt och bör alltid anges på fakturan.',
|
||||
category: 'faktura',
|
||||
},
|
||||
{
|
||||
term: 'Kreditfaktura',
|
||||
definition:
|
||||
'En "minusfaktura" som du skapar om du behöver korrigera eller makulera en redan skickad faktura. Beloppet blir negativt och kvittar ut originalfakturan.',
|
||||
category: 'faktura',
|
||||
relatedTerms: ['Faktura'],
|
||||
},
|
||||
// Bank
|
||||
{
|
||||
term: 'Clearingnummer',
|
||||
definition:
|
||||
'De första 4-5 siffrorna i ditt bankkonto som identifierar vilken bank och vilket kontor det tillhör. Exempel: 5331 = Avanza, 3300 = Nordea. Ofta separerat från kontonumret med bindestreck.',
|
||||
category: 'bank',
|
||||
relatedTerms: ['IBAN', 'BIC/SWIFT'],
|
||||
},
|
||||
{
|
||||
term: 'IBAN',
|
||||
definition:
|
||||
'Internationellt bankkontonummer som används för utlandsbetalningar. Svenska IBAN börjar med SE följt av 22 siffror. Din bank kan ge dig ditt IBAN.',
|
||||
category: 'bank',
|
||||
relatedTerms: ['BIC/SWIFT', 'Clearingnummer'],
|
||||
},
|
||||
{
|
||||
term: 'BIC/SWIFT',
|
||||
definition:
|
||||
'Bankens internationella identifieringskod, används tillsammans med IBAN för utlandsbetalningar. Exempel: SWEDSESS (Swedbank), NDEASESS (Nordea).',
|
||||
category: 'bank',
|
||||
relatedTerms: ['IBAN'],
|
||||
},
|
||||
// Företag
|
||||
{
|
||||
term: 'Enskild firma',
|
||||
simpleTerm: 'Enskild näringsverksamhet',
|
||||
definition:
|
||||
'Den enklaste företagsformen där du och företaget är samma juridiska person. Du äger allt personligen och ansvarar personligen för skulder. Lättast att starta men du betalar skatt via din privata deklaration.',
|
||||
category: 'företag',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/foretagarguiden/foretagsformer/enskildnaringsverksamhet.4.361dc8c15312eff6fd1e5dc.html',
|
||||
relatedTerms: ['Aktiebolag', 'Egenavgifter', 'NE-bilaga'],
|
||||
},
|
||||
{
|
||||
term: 'Aktiebolag',
|
||||
simpleTerm: 'AB',
|
||||
definition:
|
||||
'Företagsform där företaget är en egen juridisk person, skild från dig. Kräver 25 000 kr i aktiekapital och mer administration, men ger begränsat personligt ansvar och andra skattemöjligheter.',
|
||||
category: 'företag',
|
||||
skatteverketUrl: 'https://www.skatteverket.se/foretag/foretagarguiden/foretagsformer/aktiebolag.4.361dc8c15312eff6fd18a05.html',
|
||||
relatedTerms: ['Enskild firma', 'Bolagsskatt'],
|
||||
},
|
||||
{
|
||||
term: 'Organisationsnummer',
|
||||
definition:
|
||||
'Ditt företags unika identitetsnummer. För enskild firma är det ditt personnummer + 100 på århundradesiffran (199001011234 blir 199101011234).',
|
||||
category: 'företag',
|
||||
},
|
||||
]
|
||||
|
||||
const categoryConfig = {
|
||||
skatt: { label: 'Skatt', icon: Calculator, color: 'bg-orange-500/10 text-orange-600' },
|
||||
moms: { label: 'Moms', icon: Receipt, color: 'bg-blue-500/10 text-blue-600' },
|
||||
faktura: { label: 'Faktura', icon: FileText, color: 'bg-green-500/10 text-green-600' },
|
||||
bokföring: { label: 'Bokföring', icon: BookOpen, color: 'bg-purple-500/10 text-purple-600' },
|
||||
bank: { label: 'Bank', icon: Building2, color: 'bg-pink-500/10 text-pink-600' },
|
||||
företag: { label: 'Företag', icon: Building2, color: 'bg-cyan-500/10 text-cyan-600' },
|
||||
}
|
||||
|
||||
function TermCard({ term, isExpanded, onToggle }: { term: GlossaryTerm; isExpanded: boolean; onToggle: () => void }) {
|
||||
const config = categoryConfig[term.category]
|
||||
const CategoryIcon = config.icon
|
||||
|
||||
return (
|
||||
<Card className={cn('transition-all', isExpanded && 'ring-2 ring-primary/20')}>
|
||||
<CardContent className="pt-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn('p-2 rounded-lg', config.color)}>
|
||||
<CategoryIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-medium">{term.term}</h3>
|
||||
{term.simpleTerm && (
|
||||
<Badge variant="secondary" className="font-normal">
|
||||
{term.simpleTerm}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isExpanded && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{term.definition}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pl-11 space-y-3 animate-fade-in">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{term.definition}
|
||||
</p>
|
||||
|
||||
{term.relatedTerms && term.relatedTerms.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">Relaterat:</span>
|
||||
{term.relatedTerms.map((related) => (
|
||||
<Badge key={related} variant="outline" className="text-xs">
|
||||
{related}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{term.skatteverketUrl && (
|
||||
<HelpLink href={term.skatteverketUrl}>
|
||||
Läs mer på Skatteverket
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</HelpLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [expandedTerms, setExpandedTerms] = useState<Set<string>>(new Set())
|
||||
|
||||
const filteredTerms = useMemo(() => {
|
||||
return glossaryTerms.filter((term) => {
|
||||
// Category filter
|
||||
if (selectedCategory && term.category !== selectedCategory) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
return (
|
||||
term.term.toLowerCase().includes(query) ||
|
||||
term.simpleTerm?.toLowerCase().includes(query) ||
|
||||
term.definition.toLowerCase().includes(query) ||
|
||||
term.relatedTerms?.some((r) => r.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [searchQuery, selectedCategory])
|
||||
|
||||
const toggleTerm = (termName: string) => {
|
||||
setExpandedTerms((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(termName)) {
|
||||
next.delete(termName)
|
||||
} else {
|
||||
next.add(termName)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<header>
|
||||
<h1 className="font-display text-3xl font-medium tracking-tight mb-2">Hjälp & Ordlista</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Förklaringar av skatte- och bokföringstermer på ren svenska.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Sök efter term..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||
selectedCategory === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Alla
|
||||
</button>
|
||||
{Object.entries(categoryConfig).map(([key, config]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedCategory(selectedCategory === key ? null : key)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-lg text-sm font-medium transition-colors',
|
||||
selectedCategory === key
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{config.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Terms list */}
|
||||
<div className="space-y-3">
|
||||
{filteredTerms.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">
|
||||
Inga termer hittades för "{searchQuery}"
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTerms.map((term) => (
|
||||
<TermCard
|
||||
key={term.term}
|
||||
term={term}
|
||||
isExpanded={expandedTerms.has(term.term)}
|
||||
onToggle={() => toggleTerm(term.term)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* External resources */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Externa resurser</CardTitle>
|
||||
<CardDescription>Mer hjälp från officiella källor</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<HelpLink
|
||||
href="https://www.skatteverket.se/foretag/foretagarguiden.4.361dc8c15312eff6fd1f87f.html"
|
||||
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span>Skatteverkets företagarguide</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Omfattande guide för nya företagare
|
||||
</p>
|
||||
</HelpLink>
|
||||
<HelpLink
|
||||
href="https://www.verksamt.se/"
|
||||
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span>Verksamt.se</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Starta och driva företag i Sverige
|
||||
</p>
|
||||
</HelpLink>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import SIEUploadStep from '@/components/import/SIEUploadStep'
|
||||
import SIEPreviewStep from '@/components/import/SIEPreviewStep'
|
||||
import AccountMappingStep from '@/components/import/AccountMappingStep'
|
||||
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
|
||||
import ImportResultStep from '@/components/import/ImportResultStep'
|
||||
import { applyMappingOverride } from '@/lib/import/account-mapper'
|
||||
import type {
|
||||
ImportWizardStep,
|
||||
ParsedSIEFile,
|
||||
AccountMapping,
|
||||
ImportPreview,
|
||||
ImportResult,
|
||||
ParseIssue,
|
||||
} from '@/lib/import/types'
|
||||
import type { BASAccount } from '@/types'
|
||||
|
||||
const STEPS: ImportWizardStep[] = ['upload', 'preview', 'mapping', 'review', 'result']
|
||||
|
||||
const STEP_LABELS: Record<ImportWizardStep, string> = {
|
||||
upload: 'Ladda upp',
|
||||
preview: 'Förhandsgranskning',
|
||||
mapping: 'Kontomappning',
|
||||
review: 'Bekräfta',
|
||||
result: 'Resultat',
|
||||
}
|
||||
|
||||
export default function ImportPage() {
|
||||
const { toast } = useToast()
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState<ImportWizardStep>('upload')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Data state
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
|
||||
const [mappings, setMappings] = useState<AccountMapping[]>([])
|
||||
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
|
||||
const [preview, setPreview] = useState<ImportPreview | null>(null)
|
||||
const [issues, setIssues] = useState<ParseIssue[]>([])
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null)
|
||||
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
|
||||
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
|
||||
|
||||
// Calculate progress
|
||||
const currentStepIndex = STEPS.indexOf(step)
|
||||
const progress = ((currentStepIndex + 1) / STEPS.length) * 100
|
||||
|
||||
// Handle file selection and parsing
|
||||
const handleFileSelect = useCallback(async (selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
|
||||
const res = await fetch('/api/import/sie/parse', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate') {
|
||||
setError(data.message)
|
||||
} else if (data.error === 'validation') {
|
||||
setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`)
|
||||
} else {
|
||||
setError(data.error || 'Failed to parse file')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Store parsed data
|
||||
setParsed({
|
||||
header: data.parsed.header,
|
||||
accounts: data.parsed.accounts,
|
||||
openingBalances: [],
|
||||
closingBalances: [],
|
||||
resultBalances: [],
|
||||
vouchers: [],
|
||||
issues: data.parsed.issues,
|
||||
stats: data.parsed.stats,
|
||||
})
|
||||
setMappings(data.mappings)
|
||||
setPreview(data.preview)
|
||||
setIssues(data.parsed.issues)
|
||||
setSieAccounts(data.parsed.accounts)
|
||||
|
||||
// Fetch BAS accounts for the mapping step
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
setBasAccounts(accountsData.data || [])
|
||||
}
|
||||
|
||||
// Move to preview step
|
||||
setStep('preview')
|
||||
|
||||
toast({
|
||||
title: 'Fil analyserad',
|
||||
description: `${data.parsed.stats.totalAccounts} konton och ${data.parsed.stats.totalVouchers} verifikationer hittades`,
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to parse file')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
// Handle mapping changes
|
||||
const handleMappingChange = useCallback((sourceAccount: string, targetAccount: string, targetName: string) => {
|
||||
setMappings((prev) => applyMappingOverride(prev, sourceAccount, targetAccount, targetName))
|
||||
|
||||
// Update preview mapping status
|
||||
setPreview((prev) => {
|
||||
if (!prev) return prev
|
||||
const updatedMappings = applyMappingOverride(mappings, sourceAccount, targetAccount, targetName)
|
||||
const mapped = updatedMappings.filter((m) => m.targetAccount).length
|
||||
const unmapped = updatedMappings.length - mapped
|
||||
const lowConfidence = updatedMappings.filter((m) => m.targetAccount && m.confidence < 0.7).length
|
||||
|
||||
return {
|
||||
...prev,
|
||||
mappingStatus: {
|
||||
...prev.mappingStatus,
|
||||
mapped,
|
||||
unmapped,
|
||||
lowConfidence,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [mappings])
|
||||
|
||||
// Calculate missing accounts (unmapped accounts that could be created)
|
||||
const missingAccounts = mappings
|
||||
.filter((m) => !m.targetAccount)
|
||||
.map((m) => ({ number: m.sourceAccount, name: m.sourceName }))
|
||||
|
||||
// Handle creating missing accounts
|
||||
const handleCreateAccounts = useCallback(async () => {
|
||||
if (missingAccounts.length === 0) return
|
||||
|
||||
setIsCreatingAccounts(true)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/import/sie/create-accounts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accounts: missingAccounts }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: data.error || 'Kunde inte skapa konton',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Konton skapade',
|
||||
description: `${data.created} nya konton har lagts till i din kontoplan`,
|
||||
})
|
||||
|
||||
// Re-parse the file to get updated mappings
|
||||
if (file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const parseRes = await fetch('/api/import/sie/parse', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const parseData = await parseRes.json()
|
||||
|
||||
if (parseRes.ok) {
|
||||
setMappings(parseData.mappings)
|
||||
setPreview(parseData.preview)
|
||||
|
||||
// Refresh BAS accounts
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
if (accountsRes.ok) {
|
||||
const accountsData = await accountsRes.json()
|
||||
setBasAccounts(accountsData.data || [])
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: err instanceof Error ? err.message : 'Kunde inte skapa konton',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsCreatingAccounts(false)
|
||||
}
|
||||
}, [missingAccounts, file, toast])
|
||||
|
||||
// Handle import execution
|
||||
const handleExecuteImport = useCallback(async (options: ImportExecuteOptions) => {
|
||||
if (!file) {
|
||||
setError('No file selected')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('mappings', JSON.stringify(mappings))
|
||||
formData.append('options', JSON.stringify(options))
|
||||
|
||||
const res = await fetch('/api/import/sie/execute', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.result) {
|
||||
setImportResult(data.result)
|
||||
} else {
|
||||
setError(data.error || 'Import failed')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
setImportResult(data.result)
|
||||
}
|
||||
|
||||
// Move to result step
|
||||
setStep('result')
|
||||
|
||||
if (data.result?.success) {
|
||||
toast({
|
||||
title: 'Import genomförd',
|
||||
description: `${data.result.journalEntriesCreated} verifikationer skapades`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Import failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [file, mappings, toast])
|
||||
|
||||
// Navigation handlers
|
||||
const goToStep = (targetStep: ImportWizardStep) => {
|
||||
setStep(targetStep)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const currentIndex = STEPS.indexOf(step)
|
||||
if (currentIndex > 0) {
|
||||
setStep(STEPS[currentIndex - 1])
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewImport = () => {
|
||||
// Reset all state
|
||||
setStep('upload')
|
||||
setFile(null)
|
||||
setParsed(null)
|
||||
setMappings([])
|
||||
setPreview(null)
|
||||
setIssues([])
|
||||
setImportResult(null)
|
||||
setError(null)
|
||||
setSieAccounts([])
|
||||
setIsCreatingAccounts(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Importera bokföring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Migrera din bokföring från Fortnox, Visma eller annat bokföringssystem via SIE-fil
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={`${
|
||||
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{STEP_LABELS[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step content */}
|
||||
{step === 'upload' && (
|
||||
<SIEUploadStep
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'preview' && preview && (
|
||||
<SIEPreviewStep
|
||||
preview={preview}
|
||||
issues={issues}
|
||||
missingAccounts={missingAccounts}
|
||||
onCreateAccounts={handleCreateAccounts}
|
||||
isCreatingAccounts={isCreatingAccounts}
|
||||
onContinue={() => goToStep('mapping')}
|
||||
onBack={goBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'mapping' && (
|
||||
<AccountMappingStep
|
||||
mappings={mappings}
|
||||
basAccounts={basAccounts}
|
||||
onMappingChange={handleMappingChange}
|
||||
onContinue={() => goToStep('review')}
|
||||
onBack={goBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'review' && preview && (
|
||||
<ImportReviewStep
|
||||
preview={preview}
|
||||
mappings={mappings}
|
||||
onExecute={handleExecuteImport}
|
||||
onBack={goBack}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && importResult && (
|
||||
<ImportResultStep
|
||||
result={importResult}
|
||||
onNewImport={handleNewImport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { Loader2, ArrowLeft, AlertTriangle } from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer } from '@/types'
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
}
|
||||
|
||||
export default function CreateCreditNotePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [reason, setReason] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [id])
|
||||
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta faktura',
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.push('/invoices')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if invoice can be credited
|
||||
if (!['sent', 'paid', 'overdue'].includes(data.status)) {
|
||||
toast({
|
||||
title: 'Kan inte krediteras',
|
||||
description: 'Endast skickade, betalda eller förfallna fakturor kan krediteras',
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.push(`/invoices/${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.status === 'credited') {
|
||||
toast({
|
||||
title: 'Redan krediterad',
|
||||
description: 'Denna faktura har redan krediterats',
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.push(`/invoices/${id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
if (data.items) {
|
||||
data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
setInvoice(data as InvoiceWithRelations)
|
||||
setReason(`Krediterar faktura ${data.invoice_number}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!invoice) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
credited_invoice_id: invoice.id,
|
||||
reason,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Failed to create credit note')
|
||||
}
|
||||
|
||||
const { data: creditNote } = await response.json()
|
||||
|
||||
toast({
|
||||
title: 'Kreditfaktura skapad',
|
||||
description: `Kreditfaktura ${creditNote.invoice_number} har skapats`,
|
||||
})
|
||||
|
||||
router.push(`/invoices/${creditNote.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte skapa kreditfaktura',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const customer = invoice.customer
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Skapa kreditfaktura</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Krediterar faktura {invoice.invoice_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<Card className="border-warning/50 bg-warning/5">
|
||||
<CardContent className="flex items-start gap-4 pt-6">
|
||||
<AlertTriangle className="h-5 w-5 text-warning flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Viktigt om kreditfakturor</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
En kreditfaktura makulerar den ursprungliga fakturan helt.
|
||||
Alla belopp blir negativa och den ursprungliga fakturan markeras som krediterad.
|
||||
Denna åtgärd kan inte ångras.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Original invoice info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ursprunglig faktura</CardTitle>
|
||||
<CardDescription>
|
||||
Kreditfakturan baseras på denna faktura
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Fakturanummer:</span>
|
||||
<span className="ml-2 font-medium">{invoice.invoice_number}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Datum:</span>
|
||||
<span className="ml-2">{formatDate(invoice.invoice_date)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Kund:</span>
|
||||
<span className="ml-2">{customer.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Momsbehandling:</span>
|
||||
<span className="ml-2">{getVatTreatmentLabel(invoice.vat_treatment)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Credit note preview */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kreditfaktura förhandsgranskning</CardTitle>
|
||||
<CardDescription>
|
||||
Kreditfakturanummer: KR-{invoice.invoice_number}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
<div className="col-span-5">Beskrivning</div>
|
||||
<div className="col-span-2 text-right">Antal</div>
|
||||
<div className="col-span-1 text-center">Enhet</div>
|
||||
<div className="col-span-2 text-right">à-pris</div>
|
||||
<div className="col-span-2 text-right">Summa</div>
|
||||
</div>
|
||||
|
||||
{/* Items (negated) */}
|
||||
{invoice.items.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
|
||||
<div className="col-span-5">{item.description}</div>
|
||||
<div className="col-span-2 text-right text-destructive">
|
||||
-{Math.abs(item.quantity)}
|
||||
</div>
|
||||
<div className="col-span-1 text-center">{item.unit}</div>
|
||||
<div className="col-span-2 text-right">
|
||||
{formatCurrency(item.unit_price, invoice.currency)}
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-medium text-destructive">
|
||||
{formatCurrency(-Math.abs(item.line_total), invoice.currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals (negated) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span className="text-destructive">
|
||||
{formatCurrency(-Math.abs(invoice.subtotal), invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
|
||||
<span className="text-destructive">
|
||||
{formatCurrency(-Math.abs(invoice.vat_amount), invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
<span className="text-destructive">
|
||||
{formatCurrency(-Math.abs(invoice.total), invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
||||
<span className="text-destructive">
|
||||
{formatCurrency(-Math.abs(invoice.total_sek))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reason */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anledning</CardTitle>
|
||||
<CardDescription>
|
||||
Ange anledning till kreditering (visas på kreditfakturan)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reason">Anledning</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="T.ex. Felaktig fakturering, returnerade varor..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar...
|
||||
</>
|
||||
) : (
|
||||
'Skapa kreditfaktura'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import {
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Download,
|
||||
XCircle,
|
||||
Clock,
|
||||
Building,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
ReceiptText,
|
||||
ExternalLink,
|
||||
Bell,
|
||||
AlertTriangle,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
|
||||
sent: { label: 'Skickad', variant: 'default', icon: Send },
|
||||
paid: { label: 'Betald', variant: 'success', icon: CheckCircle },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle },
|
||||
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle },
|
||||
}
|
||||
|
||||
const reminderLevelLabels: Record<1 | 2 | 3, string> = {
|
||||
1: 'Vänlig påminnelse',
|
||||
2: 'Andra påminnelsen',
|
||||
3: 'Slutlig påminnelse'
|
||||
}
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
sent_at?: string
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
|
||||
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
|
||||
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
|
||||
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isUpdating, setIsUpdating] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isSendingEmail, setIsSendingEmail] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [id])
|
||||
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta faktura',
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.push('/invoices')
|
||||
return
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
if (data.items) {
|
||||
data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order)
|
||||
}
|
||||
|
||||
setInvoice(data as InvoiceWithRelations)
|
||||
|
||||
// Fetch reminders for this invoice
|
||||
const { data: reminderData } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.select('*')
|
||||
.eq('invoice_id', id)
|
||||
.order('sent_at', { ascending: false })
|
||||
|
||||
if (reminderData) {
|
||||
setReminders(reminderData as InvoiceReminder[])
|
||||
}
|
||||
|
||||
// If this invoice is credited, find the credit note
|
||||
if (data.status === 'credited') {
|
||||
const { data: creditNoteData } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('credited_invoice_id', id)
|
||||
.single()
|
||||
|
||||
if (creditNoteData) {
|
||||
setCreditNote(creditNoteData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a credit note, fetch the original invoice
|
||||
if (data.credited_invoice_id) {
|
||||
const { data: originalData } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number')
|
||||
.eq('id', data.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalData) {
|
||||
setOriginalInvoice(originalData as Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function updateStatus(status: InvoiceStatus) {
|
||||
if (!invoice) return
|
||||
|
||||
setIsUpdating(true)
|
||||
|
||||
const updates: Partial<Invoice> = { status }
|
||||
if (status === 'paid') {
|
||||
updates.paid_at = new Date().toISOString()
|
||||
updates.paid_amount = invoice.total
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('invoices')
|
||||
.update(updates)
|
||||
.eq('id', invoice.id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Uppdaterad',
|
||||
description: `Fakturan är nu markerad som ${statusConfig[status].label.toLowerCase()}`,
|
||||
})
|
||||
fetchInvoice()
|
||||
}
|
||||
|
||||
setIsUpdating(false)
|
||||
}
|
||||
|
||||
async function sendInvoiceEmail() {
|
||||
if (!invoice) return
|
||||
|
||||
// Check if customer has email
|
||||
if (!invoice.customer.email) {
|
||||
toast({
|
||||
title: 'E-post saknas',
|
||||
description: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSendingEmail(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/send`, {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Kunde inte skicka fakturan')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura skickad',
|
||||
description: data.message,
|
||||
})
|
||||
|
||||
// Refresh to get updated status
|
||||
fetchInvoice()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte skicka fakturan',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSendingEmail(false)
|
||||
}
|
||||
|
||||
async function downloadPDF() {
|
||||
if (!invoice) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/pdf`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Kunde inte generera PDF')
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `faktura-${invoice.invoice_number}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
|
||||
toast({
|
||||
title: 'PDF nedladdad',
|
||||
description: `Faktura ${invoice.invoice_number} har laddats ner`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte ladda ner PDF',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsDownloading(false)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null
|
||||
}
|
||||
|
||||
const status = statusConfig[invoice.status]
|
||||
const StatusIcon = status.icon
|
||||
const customer = invoice.customer
|
||||
const customerHasEmail = !!customer.email
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
<StatusIcon className="mr-1 h-3 w-3" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Skapad {formatDate(invoice.created_at)}
|
||||
{invoice.sent_at && ` • Skickad ${formatDate(invoice.sent_at)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status === 'draft' && (
|
||||
customerHasEmail ? (
|
||||
<Button onClick={sendInvoiceEmail} disabled={isSendingEmail}>
|
||||
{isSendingEmail ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Skicka via e-post
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => updateStatus('sent')} disabled={isUpdating}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Markera som skickad
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Markera som betald
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
|
||||
{isDownloading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Ladda ner PDF
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Customer info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building className="h-5 w-5" />
|
||||
Kund
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-lg">{customer.name}</p>
|
||||
{customer.org_number && (
|
||||
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
|
||||
)}
|
||||
{customer.vat_number && (
|
||||
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-4 pt-2">
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Mail className="h-4 w-4" />
|
||||
{customer.email}
|
||||
</div>
|
||||
)}
|
||||
{customer.phone && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Phone className="h-4 w-4" />
|
||||
{customer.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(customer.address_line1 || customer.city) && (
|
||||
<div className="flex items-start gap-1 text-sm text-muted-foreground pt-1">
|
||||
<MapPin className="h-4 w-4 mt-0.5" />
|
||||
<div>
|
||||
{customer.address_line1 && <p>{customer.address_line1}</p>}
|
||||
{customer.address_line2 && <p>{customer.address_line2}</p>}
|
||||
<p>
|
||||
{customer.postal_code} {customer.city}
|
||||
{customer.country !== 'SE' && `, ${customer.country}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturarader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
<div className="col-span-5">Beskrivning</div>
|
||||
<div className="col-span-2 text-right">Antal</div>
|
||||
<div className="col-span-1 text-center">Enhet</div>
|
||||
<div className="col-span-2 text-right">à-pris</div>
|
||||
<div className="col-span-2 text-right">Summa</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{invoice.items.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
|
||||
<div className="col-span-5">{item.description}</div>
|
||||
<div className="col-span-2 text-right">{item.quantity}</div>
|
||||
<div className="col-span-1 text-center">{item.unit}</div>
|
||||
<div className="col-span-2 text-right">
|
||||
{formatCurrency(item.unit_price, invoice.currency)}
|
||||
</div>
|
||||
<div className="col-span-2 text-right font-medium">
|
||||
{formatCurrency(item.line_total, invoice.currency)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
|
||||
<span>{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
<span>{formatCurrency(invoice.total, invoice.currency)}</span>
|
||||
</div>
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>I SEK (kurs {invoice.exchange_rate})</span>
|
||||
<span>{formatCurrency(invoice.total_sek)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{(invoice.notes || invoice.reverse_charge_text) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anteckningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invoice.reverse_charge_text && (
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
|
||||
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
|
||||
</div>
|
||||
)}
|
||||
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Invoice details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detaljer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fakturanummer</span>
|
||||
<span className="font-medium">{invoice.invoice_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<span>{formatDate(invoice.invoice_date)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<span>{formatDate(invoice.due_date)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Valuta</span>
|
||||
<span>{invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Momsbehandling</span>
|
||||
<span className="text-right text-sm">
|
||||
{getVatTreatmentLabel(invoice.vat_treatment)}
|
||||
</span>
|
||||
</div>
|
||||
{invoice.your_reference && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Er referens</span>
|
||||
<span>{invoice.your_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.our_reference && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Vår referens</span>
|
||||
<span>{invoice.our_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment info */}
|
||||
{invoice.status === 'paid' && invoice.paid_at && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-success">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
Betald
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Betalning mottagen {formatDate(invoice.paid_at)}
|
||||
</p>
|
||||
{invoice.paid_amount && (
|
||||
<p className="text-lg font-bold mt-2">
|
||||
{formatCurrency(invoice.paid_amount, invoice.currency)}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reminders Card */}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue' || reminders.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Påminnelser
|
||||
</CardTitle>
|
||||
{reminders.length === 0 && (
|
||||
<CardDescription>
|
||||
Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reminders.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{reminders.map((reminder) => (
|
||||
<div
|
||||
key={reminder.id}
|
||||
className="flex items-start justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={reminder.reminder_level === 3 ? 'destructive' : reminder.reminder_level === 2 ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
Nivå {reminder.reminder_level}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
{reminderLevelLabels[reminder.reminder_level as 1 | 2 | 3]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skickad {formatDate(reminder.sent_at)} till {reminder.email_to}
|
||||
</p>
|
||||
{reminder.response_type && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{reminder.response_type === 'marked_paid' ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs text-green-600">Kunden markerat som betald</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="h-3 w-3 text-orange-600" />
|
||||
<span className="text-xs text-orange-600">Kunden har invändningar</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inga påminnelser har skickats ännu.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Credit note reference (if this invoice was credited) */}
|
||||
{invoice.status === 'credited' && creditNote && (
|
||||
<Card className="border-warning/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-warning">
|
||||
<ReceiptText className="h-5 w-5" />
|
||||
Krediterad
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Denna faktura har krediterats
|
||||
</p>
|
||||
<Link href={`/invoices/${creditNote.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Se kreditfaktura {creditNote.invoice_number}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Original invoice reference (if this is a credit note) */}
|
||||
{invoice.credited_invoice_id && originalInvoice && (
|
||||
<Card className="border-primary/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ReceiptText className="h-5 w-5" />
|
||||
Kreditfaktura
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Denna kreditfaktura krediterar
|
||||
</p>
|
||||
<Link href={`/invoices/${originalInvoice.id}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Se faktura {originalInvoice.invoice_number}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Status actions */}
|
||||
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Åtgärder</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{invoice.status === 'draft' && (
|
||||
<>
|
||||
{customerHasEmail ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={sendInvoiceEmail}
|
||||
disabled={isSendingEmail}
|
||||
>
|
||||
{isSendingEmail ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Skicka via e-post
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-700">
|
||||
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('sent')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Markera som skickad
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Makulera
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('paid')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Markera som betald
|
||||
</Button>
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
Skapa kreditfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => updateStatus('cancelled')}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
Makulera
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{invoice.status === 'paid' && (
|
||||
<Link href={`/invoices/${invoice.id}/credit`} className="block">
|
||||
<Button variant="outline" className="w-full">
|
||||
<ReceiptText className="mr-2 h-4 w-4" />
|
||||
Skapa kreditfaktura
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useForm, useFieldArray, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { addDays, format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { getVatRules, getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
|
||||
import { Loader2, Plus, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import type { Customer, Currency, CreateInvoiceInput } from '@/types'
|
||||
|
||||
const itemSchema = z.object({
|
||||
description: z.string().min(1, 'Beskrivning krävs'),
|
||||
quantity: z.number().min(0.01, 'Minst 0.01'),
|
||||
unit: z.string().min(1, 'Enhet krävs'),
|
||||
unit_price: z.number().min(0, 'Pris måste vara positivt'),
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
customer_id: z.string().min(1, 'Välj en kund'),
|
||||
invoice_date: z.string().min(1, 'Fakturadatum krävs'),
|
||||
due_date: z.string().min(1, 'Förfallodatum krävs'),
|
||||
currency: z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']),
|
||||
your_reference: z.string().optional(),
|
||||
our_reference: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
items: z.array(itemSchema).min(1, 'Minst en rad krävs'),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
|
||||
const units = ['st', 'tim', 'dag', 'mån', 'km', 'kg']
|
||||
|
||||
export default function NewInvoicePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const searchParams = useSearchParams()
|
||||
const campaignId = searchParams.get('campaign_id')
|
||||
const preselectedCustomerId = searchParams.get('customer_id')
|
||||
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
customer_id: '',
|
||||
invoice_date: '',
|
||||
due_date: '',
|
||||
currency: 'SEK',
|
||||
items: [{ description: '', quantity: 1, unit: 'st', unit_price: 0 }],
|
||||
},
|
||||
})
|
||||
|
||||
// Set date defaults on client only to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
|
||||
setValue('due_date', format(addDays(new Date(), 30), 'yyyy-MM-dd'))
|
||||
}, [])
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'items',
|
||||
})
|
||||
|
||||
const watchItems = watch('items')
|
||||
const watchCurrency = watch('currency')
|
||||
const watchCustomerId = watch('customer_id')
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (watchCustomerId) {
|
||||
const customer = customers.find((c) => c.id === watchCustomerId)
|
||||
setSelectedCustomer(customer || null)
|
||||
|
||||
// Update due date based on customer payment terms
|
||||
if (customer?.default_payment_terms) {
|
||||
setValue(
|
||||
'due_date',
|
||||
format(addDays(new Date(), customer.default_payment_terms), 'yyyy-MM-dd')
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [watchCustomerId, customers, setValue])
|
||||
|
||||
async function fetchCustomers() {
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta kunder',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setCustomers(data || [])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId || customers.length === 0) return
|
||||
|
||||
async function fetchCampaign() {
|
||||
const response = await fetch(`/api/campaigns/${campaignId}`)
|
||||
if (!response.ok) return
|
||||
const { data: campaign } = await response.json()
|
||||
|
||||
if (campaign.customer_id) {
|
||||
setValue('customer_id', campaign.customer_id)
|
||||
} else if (preselectedCustomerId) {
|
||||
setValue('customer_id', preselectedCustomerId)
|
||||
}
|
||||
|
||||
if (campaign.currency) {
|
||||
setValue('currency', campaign.currency)
|
||||
}
|
||||
|
||||
if (campaign.total_value) {
|
||||
setValue('items.0.description', campaign.name || '')
|
||||
setValue('items.0.quantity', 1)
|
||||
setValue('items.0.unit', 'st')
|
||||
setValue('items.0.unit_price', campaign.total_value)
|
||||
}
|
||||
|
||||
// Calculate due date from publication_date + payment_terms
|
||||
const paymentTerms = campaign.payment_terms || 30
|
||||
const baseDate = campaign.publication_date ? new Date(campaign.publication_date) : new Date()
|
||||
setValue('due_date', format(addDays(baseDate, paymentTerms), 'yyyy-MM-dd'))
|
||||
}
|
||||
|
||||
fetchCampaign()
|
||||
}, [campaignId, customers, setValue])
|
||||
|
||||
const subtotal = watchItems.reduce((sum, item) => {
|
||||
return sum + (item.quantity || 0) * (item.unit_price || 0)
|
||||
}, 0)
|
||||
|
||||
const vatRules = selectedCustomer
|
||||
? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
|
||||
: null
|
||||
|
||||
const vatAmount = vatRules ? subtotal * (vatRules.rate / 100) : 0
|
||||
const total = subtotal + vatAmount
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data as CreateInvoiceInput),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte skapa faktura')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Faktura skapad',
|
||||
description: `Faktura ${result.data.invoice_number} har skapats`,
|
||||
})
|
||||
|
||||
router.push(`/invoices/${result.data.id}`)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Något gick fel',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Ny faktura</h1>
|
||||
<p className="text-muted-foreground">Skapa en ny faktura</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Customer selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kund</CardTitle>
|
||||
<CardDescription>Välj vilken kund fakturan ska skickas till</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Controller
|
||||
name="customer_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj kund" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{customers.map((customer) => (
|
||||
<SelectItem key={customer.id} value={customer.id}>
|
||||
{customer.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.customer_id && (
|
||||
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
|
||||
)}
|
||||
|
||||
{selectedCustomer && vatRules && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||
<p className="text-sm">
|
||||
<strong>Momsbehandling:</strong> {getVatTreatmentLabel(vatRules.treatment)}
|
||||
</p>
|
||||
{vatRules.reverseChargeText && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Omvänd skattskyldighet tillämpas
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturarader</CardTitle>
|
||||
<CardDescription>Lägg till produkter eller tjänster</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid gap-4 md:grid-cols-12 items-start">
|
||||
<div className="md:col-span-5 space-y-2">
|
||||
<Label>Beskrivning</Label>
|
||||
<Input
|
||||
placeholder="T.ex. Instagram-kampanj"
|
||||
{...register(`items.${index}.description`)}
|
||||
/>
|
||||
{errors.items?.[index]?.description && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.items[index].description?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>Antal</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>Enhet</Label>
|
||||
<Controller
|
||||
name={`items.${index}.unit`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{units.map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<Label>à-pris</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1 flex items-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length === 1}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
append({ description: '', quantity: 1, unit: 'st', unit_price: 0 })
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anteckningar</CardTitle>
|
||||
<CardDescription>Valfritt meddelande på fakturan</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="T.ex. betalningsvillkor eller tack för samarbetet..."
|
||||
{...register('notes')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Invoice details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturadetaljer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Valuta</Label>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency} value={currency}>
|
||||
{currency}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Fakturadatum</Label>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum</Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Er referens</Label>
|
||||
<Input
|
||||
placeholder="Kontaktperson hos kund"
|
||||
{...register('your_reference')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Vår referens</Label>
|
||||
<Input placeholder="Ditt namn" {...register('our_reference')} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Summering</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Delsumma</span>
|
||||
<span>{formatCurrency(subtotal, watchCurrency)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Moms ({vatRules?.rate || 25}%)
|
||||
</span>
|
||||
<span>{formatCurrency(vatAmount, watchCurrency)}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-between font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
<span>{formatCurrency(total, watchCurrency)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Button type="submit" className="w-full" size="lg" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Skapar...
|
||||
</>
|
||||
) : (
|
||||
'Skapa faktura'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle } from 'lucide-react'
|
||||
import { EmptyInvoices } from '@/components/ui/empty-state'
|
||||
import type { Invoice, InvoiceStatus } from '@/types'
|
||||
|
||||
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType; borderColor: string }> = {
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-l-muted-foreground/30' },
|
||||
sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-l-warning' },
|
||||
paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-l-success' },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-l-destructive' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-l-muted-foreground/30' },
|
||||
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-l-muted-foreground/30' },
|
||||
}
|
||||
|
||||
function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text: string; color: string } | null {
|
||||
if (status === 'paid' || status === 'cancelled' || status === 'credited' || status === 'draft') return null
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const dueDate = new Date(dueDateStr)
|
||||
dueDate.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) {
|
||||
return { text: `${Math.abs(diffDays)} dagar försenad`, color: 'text-destructive' }
|
||||
} else if (diffDays === 0) {
|
||||
return { text: 'Förfaller idag', color: 'text-warning-foreground' }
|
||||
} else if (diffDays <= 3) {
|
||||
return { text: `${diffDays} dagar kvar`, color: 'text-warning-foreground' }
|
||||
} else if (diffDays <= 7) {
|
||||
return { text: `${diffDays} dagar kvar`, color: 'text-muted-foreground' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
async function fetchInvoices() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta fakturor',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setInvoices(data || [])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const filteredInvoices = invoices.filter((invoice) => {
|
||||
const matchesSearch =
|
||||
invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(invoice.customer as { name: string })?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'unpaid' && ['sent', 'overdue'].includes(invoice.status) && !isCreditNote) ||
|
||||
(activeTab === 'credit' && isCreditNote) ||
|
||||
invoice.status === activeTab
|
||||
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
|
||||
const stats = {
|
||||
unpaid: invoices.filter((i) => ['sent', 'overdue'].includes(i.status)).length,
|
||||
unpaidAmount: invoices
|
||||
.filter((i) => ['sent', 'overdue'].includes(i.status))
|
||||
.reduce((sum, i) => sum + Number(i.total_sek || i.total), 0),
|
||||
overdue: invoices.filter((i) => i.status === 'overdue').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Fakturor"
|
||||
description="Skapa och hantera dina fakturor"
|
||||
action={
|
||||
<Link href="/invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny faktura
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Receipt className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Totalt antal</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{invoices.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-warning/10 flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Obetalda</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-2xl font-bold tabular-nums">{stats.unpaid}</p>
|
||||
{stats.overdue > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{stats.overdue} förfallna
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-gradient-to-br from-success/5 to-transparent border-success/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-12 w-12 rounded-lg bg-success/10 flex items-center justify-center">
|
||||
<Send className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Att få in</p>
|
||||
<p className="text-2xl font-bold tabular-nums">{formatCurrency(stats.unpaidAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på fakturanummer eller kund..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="unpaid">Obetalda</TabsTrigger>
|
||||
<TabsTrigger value="paid">Betalda</TabsTrigger>
|
||||
<TabsTrigger value="draft">Utkast</TabsTrigger>
|
||||
<TabsTrigger value="credit">Kredit</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Invoice list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 bg-muted rounded w-32" />
|
||||
<div className="h-4 bg-muted rounded w-48" />
|
||||
</div>
|
||||
<div className="h-8 bg-muted rounded w-24" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Receipt className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga fakturor matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
) : invoices.length === 0 ? (
|
||||
<EmptyInvoices />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Receipt className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga fakturor i denna kategori</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Prova att byta flik för att se fler fakturor
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredInvoices.map((invoice) => {
|
||||
const status = statusConfig[invoice.status]
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const StatusIcon = isCreditNote ? ReceiptText : status.icon
|
||||
const relativeTime = invoice.due_date ? getRelativeTimeLabel(invoice.due_date, invoice.status) : null
|
||||
const borderClass = isCreditNote ? 'border-l-4 border-l-destructive/50' : `border-l-4 ${status.borderColor}`
|
||||
|
||||
return (
|
||||
<Link key={invoice.id} href={`/invoices/${invoice.id}`}>
|
||||
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${borderClass} ${invoice.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`h-10 w-10 rounded-lg flex items-center justify-center ${isCreditNote ? 'bg-destructive/10' : 'bg-muted'}`}>
|
||||
<StatusIcon className={`h-5 w-5 ${isCreditNote ? 'text-destructive' : 'text-muted-foreground'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{invoice.invoice_number}</p>
|
||||
{isCreditNote && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Kredit
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(invoice.customer as { name: string })?.name} · {formatDate(invoice.invoice_date)}
|
||||
</p>
|
||||
{relativeTime && (
|
||||
<span className={`text-xs font-medium ${relativeTime.color}`}>
|
||||
{relativeTime.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`font-medium tabular-nums ${isCreditNote ? 'text-destructive' : ''}`}>
|
||||
{formatCurrency(Number(invoice.total), invoice.currency)}
|
||||
</p>
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
<p className={`text-sm tabular-nums ${isCreditNote ? 'text-destructive/70' : 'text-muted-foreground'}`}>
|
||||
{formatCurrency(Number(invoice.total_sek))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import DashboardNav from '@/components/dashboard/DashboardNav'
|
||||
import { ChatWidget } from '@/components/chat'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('company_name, onboarding_complete, entity_type')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings?.onboarding_complete) {
|
||||
redirect('/onboarding')
|
||||
}
|
||||
|
||||
const entityType = (settings.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Skip to content link for keyboard/screen reader users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-lg focus:text-sm focus:font-medium"
|
||||
>
|
||||
Hoppa till innehåll
|
||||
</a>
|
||||
<DashboardNav
|
||||
companyName={settings.company_name || 'Min verksamhet'}
|
||||
entityType={entityType}
|
||||
/>
|
||||
<main id="main-content" className="pb-20 md:pb-0 md:pl-60" role="main">
|
||||
<div className="max-w-4xl mx-auto px-6 py-10 md:py-12">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<ChatWidget />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { redirect } from 'next/navigation'
|
||||
import DashboardContent from '@/components/dashboard/DashboardContent'
|
||||
import LightDashboardContent from '@/components/dashboard/LightDashboardContent'
|
||||
import type { Gift, GiftSummary, Deadline, Campaign, ReceiptQueueSummary, OnboardingProgress, ShadowLedgerEntry } from '@/types'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
// Fetch profile for name
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('full_name')
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
const firstName = profile?.full_name?.split(' ')[0] || null
|
||||
|
||||
// Fetch company settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
// ── Light mode early return ──────────────────────────────────────────
|
||||
if (settings?.entity_type === 'light') {
|
||||
const currentYear = new Date().getFullYear()
|
||||
const lightStartOfYear = `${currentYear}-01-01`
|
||||
const lightEndOfYear = `${currentYear}-12-31`
|
||||
|
||||
// Fetch bank balance
|
||||
const { data: lightBankConnections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('accounts, status')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.limit(1)
|
||||
|
||||
let lightBankBalance: number | null = null
|
||||
if (lightBankConnections && lightBankConnections.length > 0) {
|
||||
const accounts = lightBankConnections[0].accounts as { balance: number }[] | null
|
||||
if (accounts && accounts.length > 0) {
|
||||
lightBankBalance = accounts.reduce((sum, acc) => sum + (acc.balance || 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch gifts for current year
|
||||
const { data: lightGifts } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', lightStartOfYear)
|
||||
|
||||
// Fetch shadow ledger entries for current year
|
||||
const { data: shadowLedgerEntriesRaw } = await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', lightStartOfYear)
|
||||
.lte('date', lightEndOfYear)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
const shadowLedgerEntries: ShadowLedgerEntry[] = (shadowLedgerEntriesRaw || []) as ShadowLedgerEntry[]
|
||||
|
||||
// Fetch active campaigns
|
||||
const { data: lightCampaigns } = await supabase
|
||||
.from('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
deliverables(*)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['negotiation', 'contracted', 'active'])
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Fetch upcoming deadlines (next 7 days + overdue)
|
||||
const lightToday = new Date().toISOString().split('T')[0]
|
||||
const lightNextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
const { data: lightDeadlines } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_completed', false)
|
||||
.or(`due_date.lt.${lightToday},due_date.lte.${lightNextWeek}`)
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
// Compute gift tax debt from gifts data
|
||||
const taxableGifts = (lightGifts || []).filter(
|
||||
(g: Gift) => g.classification?.taxable && !g.returned
|
||||
)
|
||||
const taxableGiftValue = taxableGifts.reduce(
|
||||
(sum: number, g: Gift) => sum + Number(g.estimated_value), 0
|
||||
)
|
||||
const municipalRate = Number(settings.municipal_tax_rate) || 0.3238
|
||||
const churchRate = settings.church_tax ? (Number(settings.church_tax_rate) || 0.01) : 0
|
||||
const effectiveRate = municipalRate + churchRate
|
||||
const giftTaxDebt = Math.round(taxableGiftValue * effectiveRate * 100) / 100
|
||||
|
||||
// Find days since last payout
|
||||
const lastPayout = shadowLedgerEntries.find(e => e.type === 'payout')
|
||||
let daysSinceLastPayout: number | null = null
|
||||
if (lastPayout) {
|
||||
const lastPayoutDate = new Date(lastPayout.date)
|
||||
const today = new Date()
|
||||
daysSinceLastPayout = Math.floor((today.getTime() - lastPayoutDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// Recent entries for payout card (last 5)
|
||||
const recentEntries = shadowLedgerEntries.slice(0, 5).map(e => ({
|
||||
id: e.id,
|
||||
date: e.date,
|
||||
description: e.description,
|
||||
gross_amount: Number(e.gross_amount),
|
||||
net_amount: Number(e.net_amount),
|
||||
service_fee: Number(e.service_fee),
|
||||
pension_deduction: Number(e.pension_deduction),
|
||||
social_fees: Number(e.social_fees),
|
||||
income_tax_withheld: Number(e.income_tax_withheld),
|
||||
platform_fee: Number(e.platform_fee),
|
||||
type: e.type,
|
||||
provider: e.provider,
|
||||
}))
|
||||
|
||||
return (
|
||||
<LightDashboardContent
|
||||
firstName={firstName}
|
||||
bankBalance={lightBankBalance}
|
||||
giftTaxDebt={giftTaxDebt}
|
||||
taxableGiftCount={taxableGifts.length}
|
||||
effectiveRate={effectiveRate}
|
||||
daysSinceLastPayout={daysSinceLastPayout}
|
||||
recentEntries={recentEntries}
|
||||
hobbyReserve={0}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── EF / AB dashboard (existing logic) ──────────────────────────────
|
||||
// Fetch onboarding progress for new user checklist
|
||||
const { count: customerCount } = await supabase
|
||||
.from('customers')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const { count: invoiceCount } = await supabase
|
||||
.from('invoices')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const { count: receiptCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const { count: bankConnectionCount } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
|
||||
const onboardingProgress: OnboardingProgress = {
|
||||
hasCustomers: (customerCount || 0) > 0,
|
||||
hasInvoices: (invoiceCount || 0) > 0,
|
||||
hasReceipts: (receiptCount || 0) > 0,
|
||||
hasBankConnected: (bankConnectionCount || 0) > 0,
|
||||
}
|
||||
|
||||
// Fetch current year transactions summary
|
||||
const startOfYear = new Date(new Date().getFullYear(), 0, 1).toISOString()
|
||||
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString()
|
||||
|
||||
const { data: transactions } = await supabase
|
||||
.from('transactions')
|
||||
.select('amount, amount_sek, is_business, category, date')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startOfYear.split('T')[0])
|
||||
|
||||
// Calculate summaries
|
||||
const ytdTransactions = transactions || []
|
||||
const mtdTransactions = ytdTransactions.filter(
|
||||
(t) => t.date >= startOfMonth.split('T')[0]
|
||||
)
|
||||
|
||||
const calculateTotals = (txns: typeof ytdTransactions) => {
|
||||
const income = txns
|
||||
.filter((t) => t.is_business && t.amount > 0)
|
||||
.reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0)
|
||||
const expenses = txns
|
||||
.filter((t) => t.is_business && t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0)
|
||||
return { income, expenses, net: income - expenses }
|
||||
}
|
||||
|
||||
const ytdTotals = calculateTotals(ytdTransactions)
|
||||
const mtdTotals = calculateTotals(mtdTransactions)
|
||||
|
||||
const uncategorizedTxns = (transactions || []).filter(
|
||||
(t) => t.is_business === null
|
||||
)
|
||||
const uncategorizedCount = uncategorizedTxns.length
|
||||
const uncategorizedIncome = uncategorizedTxns
|
||||
.filter((t) => t.amount > 0)
|
||||
.reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0)
|
||||
const uncategorizedExpenses = uncategorizedTxns
|
||||
.filter((t) => t.amount < 0)
|
||||
.reduce((sum, t) => sum + Math.abs(Number(t.amount_sek || t.amount)), 0)
|
||||
|
||||
// Fetch unpaid invoices with VAT amounts
|
||||
const { data: unpaidInvoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('total, total_sek, vat_amount, vat_amount_sek, status')
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
|
||||
const unpaidTotal = (unpaidInvoices || []).reduce(
|
||||
(sum, inv) => sum + Number(inv.total_sek || inv.total),
|
||||
0
|
||||
)
|
||||
|
||||
// Calculate VAT from unpaid invoices (this is VAT we've invoiced but not yet received)
|
||||
const unpaidVatTotal = (unpaidInvoices || []).reduce(
|
||||
(sum, inv) => sum + Number(inv.vat_amount_sek || inv.vat_amount || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const overdueCount = (unpaidInvoices || []).filter(
|
||||
(inv) => inv.status === 'overdue'
|
||||
).length
|
||||
|
||||
// Fetch mileage entries for schablonavdrag
|
||||
const { data: mileageEntries } = await supabase
|
||||
.from('mileage_entries')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startOfYear.split('T')[0])
|
||||
|
||||
// Fetch bank balance (if connected)
|
||||
const { data: bankConnections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('accounts, status')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.limit(1)
|
||||
|
||||
let bankBalance: number | null = null
|
||||
if (bankConnections && bankConnections.length > 0) {
|
||||
const accounts = bankConnections[0].accounts as { balance: number }[] | null
|
||||
if (accounts && accounts.length > 0) {
|
||||
bankBalance = accounts.reduce((sum, acc) => sum + (acc.balance || 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch upcoming deadlines (next 7 days + overdue)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
const { data: deadlines } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_completed', false)
|
||||
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`)
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
// Fetch active campaigns with deliverables
|
||||
const { data: campaigns } = await supabase
|
||||
.from('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
deliverables(*)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.in('status', ['negotiation', 'contracted', 'active'])
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Fetch gift summary for current year
|
||||
const { data: gifts } = await supabase
|
||||
.from('gifts')
|
||||
.select('estimated_value, classification')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startOfYear.split('T')[0])
|
||||
|
||||
// Fetch receipt queue summary
|
||||
const { count: pendingReviewCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'extracted')
|
||||
|
||||
const { count: unmatchedReceiptsCount } = await supabase
|
||||
.from('receipts')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
|
||||
const { count: unmatchedTransactionsCount } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id)
|
||||
.lt('amount', 0)
|
||||
.is('receipt_id', null)
|
||||
|
||||
// Calculate receipt streak
|
||||
const { data: recentReceiptActivity } = await supabase
|
||||
.from('receipts')
|
||||
.select('created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'confirmed')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
|
||||
let streakCount = 0
|
||||
if (recentReceiptActivity && recentReceiptActivity.length > 0) {
|
||||
const todayDate = new Date()
|
||||
todayDate.setHours(0, 0, 0, 0)
|
||||
|
||||
const activityDates = new Set(
|
||||
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
|
||||
)
|
||||
|
||||
let checkDate = new Date(todayDate)
|
||||
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
|
||||
streakCount++
|
||||
checkDate.setDate(checkDate.getDate() - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const receiptQueue: ReceiptQueueSummary = {
|
||||
unmatched_receipts_count: unmatchedReceiptsCount || 0,
|
||||
unmatched_transactions_count: unmatchedTransactionsCount || 0,
|
||||
pending_review_count: pendingReviewCount || 0,
|
||||
streak_count: streakCount,
|
||||
}
|
||||
|
||||
let giftSummary: GiftSummary | null = null
|
||||
if (gifts && gifts.length > 0) {
|
||||
giftSummary = {
|
||||
year: new Date().getFullYear(),
|
||||
total_count: gifts.length,
|
||||
total_value: 0,
|
||||
taxable_count: 0,
|
||||
taxable_value: 0,
|
||||
tax_free_count: 0,
|
||||
tax_free_value: 0,
|
||||
deductible_count: 0,
|
||||
deductible_value: 0,
|
||||
}
|
||||
|
||||
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
|
||||
const value = Number(gift.estimated_value)
|
||||
const classification = gift.classification
|
||||
|
||||
giftSummary.total_value += value
|
||||
|
||||
if (classification?.taxable) {
|
||||
giftSummary.taxable_count++
|
||||
giftSummary.taxable_value += value
|
||||
} else {
|
||||
giftSummary.tax_free_count++
|
||||
giftSummary.tax_free_value += value
|
||||
}
|
||||
|
||||
if (classification?.deductibleAsExpense) {
|
||||
giftSummary.deductible_count++
|
||||
giftSummary.deductible_value += value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
firstName={firstName}
|
||||
settings={settings}
|
||||
summary={{
|
||||
ytd: ytdTotals,
|
||||
mtd: mtdTotals,
|
||||
uncategorizedCount,
|
||||
uncategorizedIncome,
|
||||
uncategorizedExpenses,
|
||||
unpaidInvoicesCount: (unpaidInvoices || []).length,
|
||||
unpaidInvoicesTotal: unpaidTotal,
|
||||
unpaidVatTotal,
|
||||
overdueInvoicesCount: overdueCount,
|
||||
bankBalance,
|
||||
mileageEntries: mileageEntries || [],
|
||||
giftSummary,
|
||||
deadlines: (deadlines || []) as Deadline[],
|
||||
campaigns: (campaigns || []) as Campaign[],
|
||||
receiptQueue,
|
||||
}}
|
||||
onboardingProgress={onboardingProgress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Camera,
|
||||
Package,
|
||||
Receipt,
|
||||
Check,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ReceiptDashboard from '@/components/receipts/ReceiptDashboard'
|
||||
import ReceiptReviewView from '@/components/receipts/ReceiptReviewView'
|
||||
import TransactionMatcher from '@/components/receipts/TransactionMatcher'
|
||||
import ProductCapture from '@/components/receipts/ProductCapture'
|
||||
import type { Receipt as ReceiptType, ReceiptLineItem, ReceiptQueueSummary, ConfirmLineItemInput } from '@/types'
|
||||
|
||||
type ViewMode = 'dashboard' | 'list' | 'review' | 'match' | 'product'
|
||||
type ListFilter = 'all' | 'pending' | 'confirmed'
|
||||
|
||||
export default function ReceiptsPage() {
|
||||
const router = useRouter()
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('dashboard')
|
||||
const [listFilter, setListFilter] = useState<ListFilter>('all')
|
||||
|
||||
const [receipts, setReceipts] = useState<(ReceiptType & { line_items: ReceiptLineItem[] })[]>([])
|
||||
const [summary, setSummary] = useState<ReceiptQueueSummary | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Selected receipt for review/match
|
||||
const [selectedReceipt, setSelectedReceipt] = useState<(ReceiptType & { line_items: ReceiptLineItem[] }) | null>(null)
|
||||
|
||||
// Fetch receipts and summary
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [receiptsRes, queueRes] = await Promise.all([
|
||||
fetch('/api/receipts'),
|
||||
fetch('/api/receipts/queue'),
|
||||
])
|
||||
|
||||
const [receiptsData, queueData] = await Promise.all([
|
||||
receiptsRes.json(),
|
||||
queueRes.json(),
|
||||
])
|
||||
|
||||
if (receiptsData.data) {
|
||||
setReceipts(receiptsData.data)
|
||||
}
|
||||
|
||||
if (queueData.data?.summary) {
|
||||
setSummary(queueData.data.summary)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Filter receipts based on tab
|
||||
const filteredReceipts = receipts.filter((r) => {
|
||||
if (listFilter === 'pending') return r.status === 'extracted'
|
||||
if (listFilter === 'confirmed') return r.status === 'confirmed'
|
||||
return true
|
||||
})
|
||||
|
||||
// Handle scan receipt
|
||||
const handleScanReceipt = () => {
|
||||
router.push('/receipts/scan')
|
||||
}
|
||||
|
||||
// Handle register product
|
||||
const handleRegisterProduct = () => {
|
||||
setViewMode('product')
|
||||
}
|
||||
|
||||
// Handle view receipt queue
|
||||
const handleViewReceiptQueue = () => {
|
||||
setListFilter('pending')
|
||||
setViewMode('list')
|
||||
}
|
||||
|
||||
// Handle view transaction queue
|
||||
const handleViewTransactionQueue = () => {
|
||||
router.push('/transactions?filter=unmatched')
|
||||
}
|
||||
|
||||
// Handle receipt selection for review
|
||||
const handleSelectReceipt = (receipt: ReceiptType & { line_items: ReceiptLineItem[] }) => {
|
||||
setSelectedReceipt(receipt)
|
||||
if (receipt.status === 'extracted') {
|
||||
setViewMode('review')
|
||||
} else {
|
||||
setViewMode('match')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle confirm receipt
|
||||
const handleConfirmReceipt = async (data: {
|
||||
line_items: ConfirmLineItemInput[]
|
||||
representation_persons?: number
|
||||
representation_purpose?: string
|
||||
}) => {
|
||||
if (!selectedReceipt) return
|
||||
|
||||
const response = await fetch(`/api/receipts/${selectedReceipt.id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchData()
|
||||
setSelectedReceipt(null)
|
||||
setViewMode('dashboard')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle match receipt to transaction
|
||||
const handleMatchReceipt = async (transactionId: string, confidence: number) => {
|
||||
if (!selectedReceipt) return
|
||||
|
||||
const response = await fetch(`/api/receipts/${selectedReceipt.id}/match`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
await fetchData()
|
||||
setSelectedReceipt(null)
|
||||
setViewMode('dashboard')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle product capture complete
|
||||
const handleProductComplete = () => {
|
||||
setViewMode('dashboard')
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// Render receipt review view
|
||||
if (viewMode === 'review' && selectedReceipt) {
|
||||
return (
|
||||
<ReceiptReviewView
|
||||
receipt={selectedReceipt}
|
||||
onConfirm={handleConfirmReceipt}
|
||||
onCancel={() => {
|
||||
setSelectedReceipt(null)
|
||||
setViewMode('dashboard')
|
||||
}}
|
||||
onFindMatches={() => setViewMode('match')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render transaction matcher
|
||||
if (viewMode === 'match' && selectedReceipt) {
|
||||
return (
|
||||
<TransactionMatcher
|
||||
receipt={selectedReceipt}
|
||||
onMatch={handleMatchReceipt}
|
||||
onSkip={() => {
|
||||
setSelectedReceipt(null)
|
||||
setViewMode('dashboard')
|
||||
}}
|
||||
onClose={() => {
|
||||
setSelectedReceipt(null)
|
||||
setViewMode('dashboard')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render product capture
|
||||
if (viewMode === 'product') {
|
||||
return (
|
||||
<ProductCapture
|
||||
onComplete={handleProductComplete}
|
||||
onCancel={() => setViewMode('dashboard')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render main page
|
||||
return (
|
||||
<div className="container max-w-4xl mx-auto p-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Kvittohantering</h1>
|
||||
<Button onClick={handleScanReceipt}>
|
||||
<Camera className="mr-2 h-4 w-4" />
|
||||
Skanna kvitto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Dashboard or List view toggle */}
|
||||
<Tabs
|
||||
value={viewMode === 'list' ? 'list' : 'dashboard'}
|
||||
onValueChange={(v) => setViewMode(v as ViewMode)}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">Översikt</TabsTrigger>
|
||||
<TabsTrigger value="list">Alla kvitton</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{viewMode === 'dashboard' && (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-24" />
|
||||
</div>
|
||||
) : summary ? (
|
||||
<ReceiptDashboard
|
||||
summary={summary}
|
||||
onScanReceipt={handleScanReceipt}
|
||||
onRegisterProduct={handleRegisterProduct}
|
||||
onViewReceiptQueue={handleViewReceiptQueue}
|
||||
onViewTransactionQueue={handleViewTransactionQueue}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p>Kunde inte ladda data</p>
|
||||
<Button variant="outline" className="mt-4" onClick={fetchData}>
|
||||
Försök igen
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{viewMode === 'list' && (
|
||||
<>
|
||||
{/* List filters */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={listFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setListFilter('all')}
|
||||
>
|
||||
Alla
|
||||
</Button>
|
||||
<Button
|
||||
variant={listFilter === 'pending' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setListFilter('pending')}
|
||||
>
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Att granska
|
||||
</Button>
|
||||
<Button
|
||||
variant={listFilter === 'confirmed' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setListFilter('confirmed')}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
Bekräftade
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Receipt list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredReceipts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center py-8">
|
||||
<Receipt className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">Inga kvitton hittades</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredReceipts.map((receipt) => (
|
||||
<Card
|
||||
key={receipt.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => handleSelectReceipt(receipt)}
|
||||
>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
{receipt.image_url ? (
|
||||
<img
|
||||
src={receipt.image_url}
|
||||
alt="Receipt"
|
||||
className="h-16 w-12 object-cover rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-16 w-12 bg-muted rounded flex items-center justify-center">
|
||||
<Receipt className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{receipt.merchant_name || 'Okänt kvitto'}</p>
|
||||
{receipt.receipt_date && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(receipt.receipt_date)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<Badge
|
||||
variant={
|
||||
receipt.status === 'confirmed'
|
||||
? 'default'
|
||||
: receipt.status === 'extracted'
|
||||
? 'secondary'
|
||||
: receipt.status === 'error'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
>
|
||||
{receipt.status === 'confirmed' && 'Bekräftat'}
|
||||
{receipt.status === 'extracted' && 'Att granska'}
|
||||
{receipt.status === 'processing' && 'Analyserar...'}
|
||||
{receipt.status === 'pending' && 'Väntar'}
|
||||
{receipt.status === 'error' && 'Fel'}
|
||||
</Badge>
|
||||
{receipt.matched_transaction_id && (
|
||||
<Badge variant="outline">Kopplat</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold">
|
||||
{formatCurrency(receipt.total_amount || 0, receipt.currency)}
|
||||
</p>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground mt-2 ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import ReceiptCamera from '@/components/receipts/ReceiptCamera'
|
||||
import ReceiptReviewView from '@/components/receipts/ReceiptReviewView'
|
||||
import TransactionMatcher from '@/components/receipts/TransactionMatcher'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { Receipt, ReceiptLineItem, ConfirmLineItemInput } from '@/types'
|
||||
|
||||
type PageState = 'camera' | 'uploading' | 'review' | 'match' | 'done'
|
||||
|
||||
export default function ScanReceiptPage() {
|
||||
const router = useRouter()
|
||||
const [pageState, setPageState] = useState<PageState>('camera')
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [receipt, setReceipt] = useState<(Receipt & { line_items: ReceiptLineItem[] }) | null>(null)
|
||||
|
||||
// Handle image capture
|
||||
const handleCapture = async (imageData: string, mimeType: string) => {
|
||||
setPageState('uploading')
|
||||
setUploadError(null)
|
||||
|
||||
try {
|
||||
// Convert base64 to blob
|
||||
const byteCharacters = atob(imageData)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: mimeType })
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData()
|
||||
formData.append('image', blob, 'receipt.jpg')
|
||||
|
||||
// Upload and analyze
|
||||
const response = await fetch('/api/receipts/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.data) {
|
||||
setReceipt(data.data)
|
||||
setPageState('review')
|
||||
} else {
|
||||
setUploadError(data.error || 'Kunde inte analysera kvittot')
|
||||
setPageState('camera')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
setUploadError('Nätverksfel. Försök igen.')
|
||||
setPageState('camera')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close/cancel
|
||||
const handleClose = () => {
|
||||
router.push('/receipts')
|
||||
}
|
||||
|
||||
// Handle confirm receipt
|
||||
const handleConfirm = async (data: {
|
||||
line_items: ConfirmLineItemInput[]
|
||||
representation_persons?: number
|
||||
representation_purpose?: string
|
||||
}) => {
|
||||
if (!receipt) return
|
||||
|
||||
const response = await fetch(`/api/receipts/${receipt.id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
setReceipt(updatedData.data)
|
||||
setPageState('match')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Kunde inte bekräfta kvitto')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle match to transaction
|
||||
const handleMatch = async (transactionId: string, confidence: number) => {
|
||||
if (!receipt) return
|
||||
|
||||
const response = await fetch(`/api/receipts/${receipt.id}/match`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transaction_id: transactionId, match_confidence: confidence }),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
router.push('/receipts')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle skip matching
|
||||
const handleSkipMatch = () => {
|
||||
router.push('/receipts')
|
||||
}
|
||||
|
||||
// Render uploading state
|
||||
if (pageState === 'uploading') {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-background z-50 flex flex-col items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
||||
<p className="text-lg font-medium">Analyserar kvitto...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI läser av artiklar och belopp
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render review view
|
||||
if (pageState === 'review' && receipt) {
|
||||
return (
|
||||
<ReceiptReviewView
|
||||
receipt={receipt}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleClose}
|
||||
onFindMatches={() => setPageState('match')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render match view
|
||||
if (pageState === 'match' && receipt) {
|
||||
return (
|
||||
<TransactionMatcher
|
||||
receipt={receipt}
|
||||
onMatch={handleMatch}
|
||||
onSkip={handleSkipMatch}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Render camera (default)
|
||||
return (
|
||||
<>
|
||||
{uploadError && (
|
||||
<div className="fixed top-4 left-4 right-4 z-[60] bg-destructive text-destructive-foreground p-3 rounded-lg text-sm">
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
<ReceiptCamera onCapture={handleCapture} onClose={handleClose} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,830 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { getDaysUntilExpiry, isConsentExpiringSoon } from '@/lib/banking/enable-banking'
|
||||
import {
|
||||
Loader2,
|
||||
Building,
|
||||
CreditCard,
|
||||
User,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
LogOut,
|
||||
Share2,
|
||||
Bell,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import type { CompanySettings, BankConnection, TikTokAccount } from '@/types'
|
||||
import { BankSelector, type Bank } from '@/components/banking/BankSelector'
|
||||
import { TikTokConnectButton, TikTokAccountCard } from '@/components/tiktok'
|
||||
import { NotificationSettings } from '@/components/settings/NotificationSettings'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
|
||||
const [tiktokAccounts, setTiktokAccounts] = useState<TikTokAccount[]>([])
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
|
||||
// Light mode specific state
|
||||
const [municipalityCode, setMunicipalityCode] = useState('')
|
||||
const [municipalTaxRate, setMunicipalTaxRate] = useState('')
|
||||
const [churchTax, setChurchTax] = useState(false)
|
||||
const [churchTaxRate, setChurchTaxRate] = useState('')
|
||||
const [umbrellaProvider, setUmbrellaProvider] = useState('')
|
||||
const [umbrellaFeePercent, setUmbrellaFeePercent] = useState('')
|
||||
const [umbrellaPensionPercent, setUmbrellaPensionPercent] = useState('')
|
||||
const [umbrellaFeeCustom, setUmbrellaFeeCustom] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Handle callback messages
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
const tiktokConnected = searchParams.get('tiktok_connected')
|
||||
const tiktokError = searchParams.get('tiktok_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad och transaktioner kan hämtas.',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: decodeURIComponent(bankError),
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
|
||||
if (tiktokConnected === 'true') {
|
||||
toast({
|
||||
title: 'TikTok anslutet!',
|
||||
description: 'Ditt TikTok-konto är nu kopplat.',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
|
||||
if (tiktokError) {
|
||||
toast({
|
||||
title: 'TikTok-anslutning misslyckades',
|
||||
description: decodeURIComponent(tiktokError),
|
||||
variant: 'destructive',
|
||||
})
|
||||
router.replace('/settings')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch settings
|
||||
const { data: settingsData } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
setSettings(settingsData)
|
||||
|
||||
// Initialize light mode fields from fetched settings
|
||||
if (settingsData) {
|
||||
setMunicipalityCode(settingsData.municipality_code || '')
|
||||
setMunicipalTaxRate(settingsData.municipal_tax_rate?.toString() || '')
|
||||
setChurchTax(settingsData.church_tax || false)
|
||||
setChurchTaxRate(settingsData.church_tax_rate?.toString() || '')
|
||||
setUmbrellaProvider(settingsData.umbrella_provider || '')
|
||||
setUmbrellaFeePercent(settingsData.umbrella_fee_percent?.toString() || '')
|
||||
setUmbrellaPensionPercent(settingsData.umbrella_pension_percent?.toString() || '')
|
||||
setUmbrellaFeeCustom(settingsData.umbrella_fee_custom || false)
|
||||
}
|
||||
|
||||
// Fetch bank connections
|
||||
const { data: connections } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
setBankConnections(connections || [])
|
||||
|
||||
// Fetch TikTok accounts
|
||||
try {
|
||||
const tiktokResponse = await fetch('/api/tiktok/accounts')
|
||||
const tiktokData = await tiktokResponse.json()
|
||||
setTiktokAccounts(tiktokData.accounts || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TikTok accounts:', error)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!settings) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const isLight = settings.entity_type === 'light'
|
||||
|
||||
let updates: Record<string, unknown>
|
||||
|
||||
if (isLight) {
|
||||
updates = {
|
||||
company_name: formData.get('company_name') as string,
|
||||
municipality_code: municipalityCode || null,
|
||||
municipal_tax_rate: parseFloat(municipalTaxRate) || null,
|
||||
church_tax: churchTax,
|
||||
church_tax_rate: churchTax ? (parseFloat(churchTaxRate) || null) : null,
|
||||
umbrella_provider: umbrellaProvider || null,
|
||||
umbrella_fee_percent: parseFloat(umbrellaFeePercent) || null,
|
||||
umbrella_pension_percent: parseFloat(umbrellaPensionPercent) || null,
|
||||
umbrella_fee_custom: umbrellaFeeCustom,
|
||||
}
|
||||
} else {
|
||||
updates = {
|
||||
company_name: formData.get('company_name') as string,
|
||||
org_number: formData.get('org_number') as string,
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
city: formData.get('city') as string,
|
||||
bank_name: formData.get('bank_name') as string,
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Sparat',
|
||||
description: 'Dina inställningar har uppdaterats',
|
||||
})
|
||||
setSettings({ ...settings, ...updates } as typeof settings)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte spara inställningar',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function handleConnectBank(bank: Bank) {
|
||||
setIsConnecting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: bank.name, aspsp_country: bank.country }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
// Redirect to bank authorization
|
||||
window.location.href = data.authorization_url
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncTransactions(connectionId: string) {
|
||||
setIsSyncing(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Synkronisering klar',
|
||||
description: `${data.imported} nya transaktioner importerade`,
|
||||
})
|
||||
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSyncing(false)
|
||||
}
|
||||
|
||||
async function handleDisconnectBank(connectionId: string) {
|
||||
const { error } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'revoked' })
|
||||
.eq('id', connectionId)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte koppla bort bank',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Bank bortkopplad',
|
||||
description: 'Bankanslutningen har tagits bort',
|
||||
})
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const activeConnections = bankConnections.filter((c) => c.status === 'active')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Inställningar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina företags- och kontoinställningar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="company" className="space-y-6">
|
||||
<TabsList className="flex-wrap h-auto gap-1">
|
||||
<TabsTrigger value="company">
|
||||
<Building className="mr-2 h-4 w-4" />
|
||||
Företag
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="banking">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Bank
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="social">
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
Sociala medier
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications">
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aviseringar
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="calendar">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Kalender
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="account">
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
Konto
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Company settings */}
|
||||
<TabsContent value="company">
|
||||
{settings?.entity_type === 'light' ? (
|
||||
/* ---- Light mode: Personuppgifter ---- */
|
||||
<form onSubmit={handleSaveSettings}>
|
||||
<div className="space-y-6">
|
||||
{/* Personal details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Ditt namn som visas i appen
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Namn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tax settings for light mode */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteinställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Kommunalskatt och kyrkoskatt som används för att beräkna din skatteskuld
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Municipality section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">Kommun</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="municipality_code">Kommun</Label>
|
||||
<Input
|
||||
id="municipality_code"
|
||||
placeholder="T.ex. Stockholm"
|
||||
value={municipalityCode}
|
||||
onChange={(e) => setMunicipalityCode(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ange din kommun för att beräkna kommunalskatt
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="municipal_tax_rate">Total kommunalskatt (%)</Label>
|
||||
<Input
|
||||
id="municipal_tax_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="T.ex. 32.38"
|
||||
value={municipalTaxRate}
|
||||
onChange={(e) => setMunicipalTaxRate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kommunalskatt + landstingsskatt + begravningsavgift
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Church tax section */}
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">Kyrkoavgift</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Aktivera om du betalar kyrkoavgift
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={churchTax}
|
||||
onCheckedChange={setChurchTax}
|
||||
/>
|
||||
</div>
|
||||
{churchTax && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="church_tax_rate">Kyrkoavgift (%)</Label>
|
||||
<Input
|
||||
id="church_tax_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="T.ex. 1.00"
|
||||
value={churchTaxRate}
|
||||
onChange={(e) => setChurchTaxRate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Umbrella provider section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Egenanställningsföretag</CardTitle>
|
||||
<CardDescription>
|
||||
Välj ditt egenanställningsföretag för att beräkna avgifter automatiskt
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantör</Label>
|
||||
<Select
|
||||
value={umbrellaProvider}
|
||||
onValueChange={setUmbrellaProvider}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj leverantör" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="frilans_finans">Frilans Finans</SelectItem>
|
||||
<SelectItem value="cool_company">Cool Company</SelectItem>
|
||||
<SelectItem value="gigapay">Gigapay</SelectItem>
|
||||
<SelectItem value="other">Annan</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{umbrellaProvider && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="umbrella_fee_percent">Serviceavgift (%)</Label>
|
||||
<Input
|
||||
id="umbrella_fee_percent"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="T.ex. 6.00"
|
||||
value={umbrellaFeePercent}
|
||||
onChange={(e) => setUmbrellaFeePercent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="umbrella_pension_percent">Pensionsavsättning (%)</Label>
|
||||
<Input
|
||||
id="umbrella_pension_percent"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="T.ex. 4.50"
|
||||
value={umbrellaPensionPercent}
|
||||
onChange={(e) => setUmbrellaPensionPercent(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
<Label>Anpassa avgifter</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Åsidosätt standardavgifter med egna värden
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={umbrellaFeeCustom}
|
||||
onCheckedChange={setUmbrellaFeeCustom}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
/* ---- EF/AB mode: Företagsuppgifter (existing) ---- */
|
||||
<form onSubmit={handleSaveSettings}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Dessa uppgifter visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Bankuppgifter för fakturor</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
name="bank_name"
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
defaultValue={settings?.account_number || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Skatteinställningar</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings */}
|
||||
<TabsContent value="banking" className="space-y-6">
|
||||
{/* Connected banks */}
|
||||
{activeConnections.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslutna banker</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{connection.bank_name}</p>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Senast synkad: {formatDate(connection.last_synced_at || connection.created_at)}
|
||||
</span>
|
||||
{isExpiring && (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{daysUntilExpiry} dagar kvar
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSyncTransactions(connection.id)}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnectBank(connection.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connect new bank */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut bank</CardTitle>
|
||||
<CardDescription>
|
||||
Koppla din bank för att automatiskt importera transaktioner via PSD2
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BankSelector
|
||||
onSelect={handleConnectBank}
|
||||
isLoading={isConnecting}
|
||||
country="SE"
|
||||
sandbox={true}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
|
||||
aldrig flytta pengar.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Social media settings */}
|
||||
<TabsContent value="social" className="space-y-6">
|
||||
{/* Connected TikTok accounts */}
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kopplade konton</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{tiktokAccounts.map((account) => (
|
||||
<TikTokAccountCard
|
||||
key={account.id}
|
||||
account={account}
|
||||
onDisconnect={fetchData}
|
||||
onSync={fetchData}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Connect TikTok */}
|
||||
{!tiktokAccounts.some(a => a.status === 'active') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anslut TikTok</CardTitle>
|
||||
<CardDescription>
|
||||
Koppla ditt TikTok-konto för att se statistik och analysera kampanjprestanda
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TikTokConnectButton />
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Vi använder TikToks officiella API och begär endast läsrättigheter för statistik.
|
||||
Vi kan aldrig posta eller ändra något på ditt konto.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Notification settings */}
|
||||
<TabsContent value="notifications">
|
||||
<NotificationSettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* Calendar feed settings */}
|
||||
<TabsContent value="calendar">
|
||||
<CalendarFeedSettings />
|
||||
</TabsContent>
|
||||
|
||||
{/* Account settings */}
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Logga ut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logga ut från ditt konto
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import ShadowLedgerForm from '@/components/shadow-ledger/ShadowLedgerForm'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { CreateShadowLedgerEntryInput } from '@/types'
|
||||
|
||||
interface UmbrellaSettings {
|
||||
umbrella_provider: string | null
|
||||
umbrella_fee_percent: number | null
|
||||
umbrella_pension_percent: number | null
|
||||
municipal_tax_rate: number | null
|
||||
}
|
||||
|
||||
export default function NewShadowLedgerEntryPage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
|
||||
const [settings, setSettings] = useState<UmbrellaSettings | undefined>(undefined)
|
||||
const [isLoadingSettings, setIsLoadingSettings] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// Fetch umbrella settings from company_settings
|
||||
useEffect(() => {
|
||||
async function fetchSettings() {
|
||||
try {
|
||||
const supabase = createClient()
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select(
|
||||
'umbrella_provider, umbrella_fee_percent, umbrella_pension_percent, municipal_tax_rate'
|
||||
)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to fetch settings:', error)
|
||||
return
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setSettings({
|
||||
umbrella_provider: data.umbrella_provider,
|
||||
umbrella_fee_percent: data.umbrella_fee_percent,
|
||||
umbrella_pension_percent: data.umbrella_pension_percent,
|
||||
municipal_tax_rate: data.municipal_tax_rate,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
} finally {
|
||||
setIsLoadingSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSettings()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (data: CreateShadowLedgerEntryInput) => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch('/api/shadow-ledger', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Kunde inte spara post')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Post sparad',
|
||||
description: data.description || 'Ny skuggbokf\u00f6ringspost skapad',
|
||||
})
|
||||
|
||||
router.push('/shadow-ledger')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description:
|
||||
error instanceof Error ? error.message : 'Kunde inte spara post',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back link */}
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/shadow-ledger">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka till skuggbokf\u00f6ring
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card className="max-w-2xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Ny post</CardTitle>
|
||||
<CardDescription>
|
||||
Registrera en utbetalning, g\u00e5va eller annan transaktion
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingSettings ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<ShadowLedgerForm
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isSubmitting}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import ShadowLedgerList from '@/components/shadow-ledger/ShadowLedgerList'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
Plus,
|
||||
Wallet,
|
||||
ArrowDownToLine,
|
||||
Receipt,
|
||||
ShieldAlert,
|
||||
PiggyBank,
|
||||
Landmark,
|
||||
} from 'lucide-react'
|
||||
import type { ShadowLedgerEntry, ShadowLedgerSummary } from '@/types'
|
||||
|
||||
export default function ShadowLedgerPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
|
||||
// State
|
||||
const [entries, setEntries] = useState<ShadowLedgerEntry[]>([])
|
||||
const [summary, setSummary] = useState<ShadowLedgerSummary | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
// Year filter
|
||||
const currentYear = new Date().getFullYear()
|
||||
const [selectedYear, setSelectedYear] = useState(
|
||||
searchParams.get('year') || currentYear.toString()
|
||||
)
|
||||
const years = Array.from({ length: 5 }, (_, i) => currentYear - i)
|
||||
|
||||
// Fetch entries + summary
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [entriesRes, summaryRes] = await Promise.all([
|
||||
fetch(`/api/shadow-ledger?year=${selectedYear}`),
|
||||
fetch(`/api/shadow-ledger/summary?year=${selectedYear}`),
|
||||
])
|
||||
|
||||
if (entriesRes.ok) {
|
||||
const entriesData = await entriesRes.json()
|
||||
setEntries(entriesData.data || [])
|
||||
}
|
||||
|
||||
if (summaryRes.ok) {
|
||||
const summaryData = await summaryRes.json()
|
||||
setSummary(summaryData.data || null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch shadow ledger:', error)
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte h\u00e4mta skuggbokf\u00f6ring',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [selectedYear, toast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
// Year change
|
||||
const handleYearChange = (year: string) => {
|
||||
setSelectedYear(year)
|
||||
router.push(`/shadow-ledger?year=${year}`)
|
||||
}
|
||||
|
||||
// Delete entry
|
||||
const handleDelete = async (id: string) => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/shadow-ledger/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) {
|
||||
const error = await res.json()
|
||||
throw new Error(error.error || 'Kunde inte ta bort post')
|
||||
}
|
||||
|
||||
toast({ title: 'Post borttagen' })
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description:
|
||||
error instanceof Error ? error.message : 'Kunde inte ta bort post',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Summary cards data
|
||||
const summaryCards = summary
|
||||
? [
|
||||
{
|
||||
label: 'Brutto i \u00e5r',
|
||||
value: formatCurrency(summary.total_gross),
|
||||
icon: Wallet,
|
||||
color: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
label: 'Netto i \u00e5r',
|
||||
value: formatCurrency(summary.total_net),
|
||||
icon: ArrowDownToLine,
|
||||
color: 'text-sky-600',
|
||||
},
|
||||
{
|
||||
label: 'Avgifter betalda',
|
||||
value: formatCurrency(summary.total_fees),
|
||||
icon: Receipt,
|
||||
color: 'text-amber-600',
|
||||
},
|
||||
{
|
||||
label: 'Skatt inneh\u00e5llen',
|
||||
value: formatCurrency(summary.total_tax_withheld),
|
||||
icon: Landmark,
|
||||
color: 'text-red-600',
|
||||
},
|
||||
{
|
||||
label: 'Pension avsatt',
|
||||
value: formatCurrency(summary.total_pension),
|
||||
icon: PiggyBank,
|
||||
color: 'text-violet-600',
|
||||
},
|
||||
{
|
||||
label: 'Virtuell skatteskuld',
|
||||
value: formatCurrency(summary.virtual_tax_debt),
|
||||
icon: ShieldAlert,
|
||||
color: 'text-destructive',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Skuggbokf\u00f6ring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
\u00d6versikt \u00f6ver utbetalningar, avgifter och skatt
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedYear} onValueChange={handleYearChange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button asChild>
|
||||
<Link href="/shadow-ledger/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny post
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-6">
|
||||
<Skeleton className="h-4 w-28 mb-2" />
|
||||
<Skeleton className="h-8 w-36" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{summaryCards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<card.icon className={`h-4 w-4 ${card.color}`} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{card.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{card.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Entry List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="pt-4">
|
||||
<Skeleton className="h-6 w-48 mb-2" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ShadowLedgerList
|
||||
entries={entries}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getCategoryDisplayName } from '@/lib/tax/expense-warnings'
|
||||
import { Plus, Search, ArrowLeftRight, ArrowUpRight, ArrowDownRight, Sparkles, Check, FileText, Link2 } from 'lucide-react'
|
||||
import TransactionForm from '@/components/transactions/TransactionForm'
|
||||
import SwipeCategorizationView from '@/components/transactions/SwipeCategorizationView'
|
||||
import type { Transaction, TransactionCategory, CreateTransactionInput, Invoice, Customer } from '@/types'
|
||||
import type { SuggestedCategory } from '@/lib/transactions/category-suggestions'
|
||||
|
||||
interface TransactionWithInvoice extends Transaction {
|
||||
potential_invoice?: Invoice & { customer?: Customer }
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
const [transactions, setTransactions] = useState<TransactionWithInvoice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [showSwipeView, setShowSwipeView] = useState(false)
|
||||
const [matchDialogOpen, setMatchDialogOpen] = useState(false)
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
const [isConfirmingMatch, setIsConfirmingMatch] = useState(false)
|
||||
const [categorySuggestions, setCategorySuggestions] = useState<Record<string, SuggestedCategory[]>>({})
|
||||
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions()
|
||||
}, [])
|
||||
|
||||
async function fetchTransactions() {
|
||||
setIsLoading(true)
|
||||
|
||||
// Fetch transactions
|
||||
const { data: txData, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta transaktioner',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get potential invoice IDs
|
||||
const potentialInvoiceIds = (txData || [])
|
||||
.filter((t) => t.potential_invoice_id)
|
||||
.map((t) => t.potential_invoice_id)
|
||||
|
||||
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
|
||||
|
||||
if (potentialInvoiceIds.length > 0) {
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.in('id', potentialInvoiceIds)
|
||||
|
||||
if (invoices) {
|
||||
invoiceMap = invoices.reduce((acc, inv) => {
|
||||
acc[inv.id] = inv
|
||||
return acc
|
||||
}, {} as Record<string, Invoice & { customer?: Customer }>)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge potential invoices into transactions
|
||||
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
|
||||
...t,
|
||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||
}))
|
||||
|
||||
setTransactions(transactionsWithInvoices)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleCreateTransaction(data: CreateTransactionInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Du måste vara inloggad',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsCreating(false)
|
||||
return
|
||||
}
|
||||
|
||||
const { data: transaction, error } = await supabase
|
||||
.from('transactions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: data.date,
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
currency: data.currency,
|
||||
category: data.category || 'uncategorized',
|
||||
is_business: data.is_business,
|
||||
notes: data.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error.message,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Transaktion tillagd',
|
||||
description: `${data.description} har lagts till`,
|
||||
})
|
||||
setTransactions([transaction, ...transactions])
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
async function handleCategorize(id: string, isBusiness: boolean, category?: TransactionCategory) {
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${id}/categorize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_business: isBusiness, category }),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error || 'Kunde inte uppdatera transaktion',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setTransactions(
|
||||
transactions.map((t) =>
|
||||
t.id === id
|
||||
? {
|
||||
...t,
|
||||
is_business: isBusiness,
|
||||
category: result.category,
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
|
||||
// Show appropriate toast
|
||||
if (result.journal_entry_created) {
|
||||
toast({
|
||||
title: 'Bokförd',
|
||||
description: 'Transaktion kategoriserad och verifikation skapad',
|
||||
})
|
||||
} else if (result.journal_entry_error) {
|
||||
toast({
|
||||
title: 'Kategoriserad',
|
||||
description: `Bokföring misslyckades: ${result.journal_entry_error}`,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Kategoriserad',
|
||||
description: 'Transaktion uppdaterad men kunde inte bokföras',
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Något gick fel vid kategorisering',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmInvoiceMatch() {
|
||||
if (!selectedTransaction || !selectedTransaction.potential_invoice) return
|
||||
|
||||
setIsConfirmingMatch(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error || 'Kunde inte matcha faktura',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsConfirmingMatch(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setTransactions(
|
||||
transactions.map((t) =>
|
||||
t.id === selectedTransaction.id
|
||||
? {
|
||||
...t,
|
||||
invoice_id: selectedTransaction.potential_invoice?.id || null,
|
||||
potential_invoice_id: null,
|
||||
potential_invoice: undefined,
|
||||
is_business: true,
|
||||
category: 'income_services' as TransactionCategory,
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Faktura matchad',
|
||||
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
|
||||
})
|
||||
|
||||
setMatchDialogOpen(false)
|
||||
setSelectedTransaction(null)
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Något gick fel vid matchning',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsConfirmingMatch(false)
|
||||
}
|
||||
|
||||
function openMatchDialog(transaction: TransactionWithInvoice) {
|
||||
setSelectedTransaction(transaction)
|
||||
setMatchDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ invoice_id: invoiceId }),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error || 'Kunde inte matcha faktura',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the invoice number for the toast
|
||||
const transaction = transactions.find(t => t.id === transactionId)
|
||||
const invoiceNumber = transaction?.potential_invoice?.invoice_number || ''
|
||||
|
||||
// Update local state
|
||||
setTransactions(
|
||||
transactions.map((t) =>
|
||||
t.id === transactionId
|
||||
? {
|
||||
...t,
|
||||
invoice_id: invoiceId,
|
||||
potential_invoice_id: null,
|
||||
potential_invoice: undefined,
|
||||
is_business: true,
|
||||
category: 'income_services' as TransactionCategory,
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
}
|
||||
: t
|
||||
)
|
||||
)
|
||||
|
||||
toast({
|
||||
title: 'Faktura matchad',
|
||||
description: `Faktura ${invoiceNumber} markerad som betald`,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Något gick fel vid matchning',
|
||||
variant: 'destructive',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategorySuggestions(txIds: string[]) {
|
||||
if (txIds.length === 0) return
|
||||
setIsLoadingSuggestions(true)
|
||||
try {
|
||||
const response = await fetch('/api/transactions/suggest-categories', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transaction_ids: txIds }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.suggestions) {
|
||||
setCategorySuggestions(data.suggestions)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, swipe still works without suggestions
|
||||
}
|
||||
setIsLoadingSuggestions(false)
|
||||
}
|
||||
|
||||
async function runBatchInvoiceMatching() {
|
||||
try {
|
||||
const response = await fetch('/api/transactions/batch-match-invoices', {
|
||||
method: 'POST',
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.matched > 0) {
|
||||
// Refresh transactions to get updated potential_invoice_ids
|
||||
await fetchTransactions()
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function openSwipeView() {
|
||||
// Run batch invoice matching for income transactions first
|
||||
await runBatchInvoiceMatching()
|
||||
const uncatIds = uncategorizedTransactions.map((t) => t.id)
|
||||
await fetchCategorySuggestions(uncatIds)
|
||||
setShowSwipeView(true)
|
||||
}
|
||||
|
||||
const uncategorizedTransactions = transactions
|
||||
.filter((t) => t.is_business === null)
|
||||
.sort((a, b) => {
|
||||
// Invoice-matched first
|
||||
const aHasMatch = a.potential_invoice ? 1 : 0
|
||||
const bHasMatch = b.potential_invoice ? 1 : 0
|
||||
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
|
||||
// Then by date descending
|
||||
return b.date.localeCompare(a.date)
|
||||
})
|
||||
const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id)
|
||||
const filteredTransactions = transactions.filter((t) => {
|
||||
const matchesSearch = t.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesTab =
|
||||
activeTab === 'all' ||
|
||||
(activeTab === 'uncategorized' && t.is_business === null) ||
|
||||
(activeTab === 'business' && t.is_business === true) ||
|
||||
(activeTab === 'private' && t.is_business === false) ||
|
||||
(activeTab === 'matches' && t.potential_invoice && !t.invoice_id)
|
||||
return matchesSearch && matchesTab
|
||||
})
|
||||
|
||||
if (showSwipeView && uncategorizedTransactions.length > 0) {
|
||||
return (
|
||||
<SwipeCategorizationView
|
||||
transactions={uncategorizedTransactions}
|
||||
suggestions={categorySuggestions}
|
||||
onCategorize={handleCategorize}
|
||||
onMatchInvoice={handleMatchInvoice}
|
||||
onClose={() => setShowSwipeView(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Transaktioner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera och kategorisera dina transaktioner
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Button variant="outline" onClick={openSwipeView} disabled={isLoadingSuggestions}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{isLoadingSuggestions ? 'Laddar...' : `Kategorisera (${uncategorizedTransactions.length})`}
|
||||
</Button>
|
||||
)}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny transaktion
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lägg till transaktion</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TransactionForm onSubmit={handleCreateTransaction} isLoading={isCreating} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and tabs */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök transaktioner..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="uncategorized">
|
||||
Ej kategoriserade
|
||||
{uncategorizedTransactions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{uncategorizedTransactions.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="matches">
|
||||
Fakturamatchningar
|
||||
{transactionsWithMatches.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{transactionsWithMatches.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="business">Företag</TabsTrigger>
|
||||
<TabsTrigger value="private">Privat</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Transaction list */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-5 bg-muted rounded w-48" />
|
||||
<div className="h-4 bg-muted rounded w-24" />
|
||||
</div>
|
||||
<div className="h-6 bg-muted rounded w-20" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredTransactions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<ArrowLeftRight className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga transaktioner</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
{searchTerm
|
||||
? 'Inga transaktioner matchar din sökning'
|
||||
: 'Lägg till din första transaktion eller anslut din bank'}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till transaktion
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredTransactions.map((transaction) => (
|
||||
<Card
|
||||
key={transaction.id}
|
||||
className={`hover:border-primary/50 transition-colors ${
|
||||
transaction.is_business === null ? 'border-warning/50' : ''
|
||||
} ${transaction.potential_invoice && !transaction.invoice_id ? 'border-blue-500/50' : ''}`}
|
||||
>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`h-10 w-10 rounded-full flex items-center justify-center ${
|
||||
transaction.amount > 0
|
||||
? 'bg-success/10 text-success'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? (
|
||||
<ArrowUpRight className="h-5 w-5" />
|
||||
) : (
|
||||
<ArrowDownRight className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{formatDate(transaction.date)}</span>
|
||||
{transaction.is_business !== null && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge
|
||||
variant={transaction.is_business ? 'default' : 'secondary'}
|
||||
>
|
||||
{transaction.is_business
|
||||
? getCategoryDisplayName(transaction.category)
|
||||
: 'Privat'}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-blue-600 border-blue-600">
|
||||
<Link2 className="h-3 w-3 mr-1" />
|
||||
Kopplad till faktura
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.journal_entry_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-success border-success">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Bokförd
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.is_business === null && !transaction.potential_invoice && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge variant="outline" className="text-warning border-warning">
|
||||
Ej kategoriserad
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{transaction.potential_invoice && !transaction.invoice_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-blue-600 border-blue-600 cursor-pointer hover:bg-blue-50"
|
||||
onClick={() => openMatchDialog(transaction)}
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Möjlig match: Faktura {transaction.potential_invoice.invoice_number}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p
|
||||
className={`font-medium ${
|
||||
transaction.amount > 0 ? 'text-success' : ''
|
||||
}`}
|
||||
>
|
||||
{transaction.amount > 0 ? '+' : ''}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
{transaction.currency !== 'SEK' && transaction.amount_sek && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(transaction.amount_sek)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invoice Match Confirmation Dialog */}
|
||||
<Dialog open={matchDialogOpen} onOpenChange={setMatchDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bekräfta fakturamatchning</DialogTitle>
|
||||
<DialogDescription>
|
||||
Vill du koppla denna transaktion till fakturan? Fakturan kommer att markeras som betald.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedTransaction?.potential_invoice && (
|
||||
<div className="space-y-4">
|
||||
{/* Transaction details */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
|
||||
<p className="font-medium">{selectedTransaction.description}</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">{formatDate(selectedTransaction.date)}</span>
|
||||
<span className="font-medium text-success">
|
||||
+{formatCurrency(selectedTransaction.amount, selectedTransaction.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Faktura</p>
|
||||
<p className="font-medium">
|
||||
Faktura {selectedTransaction.potential_invoice.invoice_number}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedTransaction.potential_invoice.customer?.name || 'Okänd kund'}
|
||||
</p>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
Förfaller: {formatDate(selectedTransaction.potential_invoice.due_date)}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{formatCurrency(
|
||||
selectedTransaction.potential_invoice.total,
|
||||
selectedTransaction.potential_invoice.currency
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What will happen */}
|
||||
<div className="rounded-lg bg-muted/50 p-4 space-y-2">
|
||||
<p className="text-sm font-medium">Vid bekräftelse:</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Transaktionen kopplas till fakturan</li>
|
||||
<li>• Fakturan markeras som betald</li>
|
||||
<li>• Bokföringsverifikation skapas automatiskt</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setMatchDialogOpen(false)}
|
||||
disabled={isConfirmingMatch}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmInvoiceMatch}
|
||||
disabled={isConfirmingMatch}
|
||||
>
|
||||
{isConfirmingMatch ? 'Bekräftar...' : 'Bekräfta matchning'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
|
||||
|
||||
import Step1EntityType from '@/components/onboarding/Step1EntityType'
|
||||
import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails'
|
||||
import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
|
||||
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
|
||||
import Step6ConnectBank from '@/components/onboarding/Step6ConnectBank'
|
||||
import Step2LightPersonalInfo from '@/components/onboarding/Step2LightPersonalInfo'
|
||||
import Step3LightTaxProfile from '@/components/onboarding/Step3LightTaxProfile'
|
||||
|
||||
const EF_AB_STEP_TITLES = [
|
||||
'Verksamhetsform',
|
||||
'Företagsuppgifter',
|
||||
'Skatteregistrering',
|
||||
'F-skatt',
|
||||
'Anslut bank',
|
||||
]
|
||||
|
||||
const LIGHT_STEP_TITLES = [
|
||||
'Verksamhetsform',
|
||||
'Dina uppgifter',
|
||||
'Skatteprofil',
|
||||
'Anslut bank',
|
||||
]
|
||||
|
||||
export default function OnboardingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
|
||||
<OnboardingPageContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function OnboardingPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
|
||||
|
||||
const isLight = settings.entity_type === 'light'
|
||||
const totalSteps = isLight ? 4 : 5
|
||||
const stepTitles = isLight ? LIGHT_STEP_TITLES : EF_AB_STEP_TITLES
|
||||
|
||||
// Load existing settings on mount
|
||||
useEffect(() => {
|
||||
async function loadSettings() {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (data) {
|
||||
setSettings(data)
|
||||
setCurrentStep(data.onboarding_step || 1)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadSettings()
|
||||
}, [supabase, router, toast])
|
||||
|
||||
// Handle bank_connected callback from PSD2 flow
|
||||
useEffect(() => {
|
||||
if (searchParams.get('bank_connected') === 'true') {
|
||||
saveSettings({ onboarding_complete: true }).then((success) => {
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är kopplad och profilen är redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const saveSettings = async (updates: Partial<CompanySettings>, nextStep?: number) => {
|
||||
setIsSaving(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return false
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
...settings,
|
||||
...updates,
|
||||
onboarding_step: nextStep || currentStep,
|
||||
}
|
||||
|
||||
// Remove read-only fields before updating
|
||||
const { id: _id, user_id: _uid, created_at: _ca, updated_at: _ua, ...settingsToSave } = updatedSettings as Record<string, unknown>
|
||||
|
||||
const { error } = await supabase
|
||||
.from('company_settings')
|
||||
.upsert({ ...settingsToSave, user_id: user.id }, { onConflict: 'user_id' })
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving settings:', JSON.stringify(error), 'code:', error.code, 'message:', error.message, 'details:', error.details)
|
||||
console.error('Payload was:', JSON.stringify({ ...settingsToSave, user_id: user.id }))
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error.message || 'Kunde inte spara. Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsSaving(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setSettings(updatedSettings)
|
||||
setIsSaving(false)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleNext = async (stepData: Partial<CompanySettings>) => {
|
||||
const entityType = stepData.entity_type || settings.entity_type
|
||||
const isLightMode = entityType === 'light'
|
||||
const stepsTotal = isLightMode ? 4 : 5
|
||||
const nextStep = currentStep + 1
|
||||
const success = await saveSettings(stepData, nextStep)
|
||||
|
||||
if (success) {
|
||||
// After step 1 (entity type selection): seed chart of accounts (skip for light)
|
||||
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== 'light') {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
await supabase.rpc('seed_chart_of_accounts', {
|
||||
p_user_id: user.id,
|
||||
p_entity_type: stepData.entity_type,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to seed chart of accounts:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// After step 3 (tax registration for EF/AB): create initial fiscal period (skip for light)
|
||||
if (currentStep === 3 && !isLightMode) {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (user) {
|
||||
const startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01`
|
||||
let endYear: number
|
||||
let endMonth: number
|
||||
if (startMonth === 1) {
|
||||
endYear = currentYear
|
||||
endMonth = 12
|
||||
} else {
|
||||
endYear = currentYear + 1
|
||||
endMonth = startMonth - 1
|
||||
}
|
||||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||||
const endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
await supabase.from('fiscal_periods').upsert({
|
||||
user_id: user.id,
|
||||
name: `Räkenskapsår ${currentYear}`,
|
||||
period_start: startStr,
|
||||
period_end: endStr,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create fiscal period:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (nextStep > stepsTotal) {
|
||||
await saveSettings({ onboarding_complete: true }, stepsTotal)
|
||||
router.push('/')
|
||||
} else {
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = async () => {
|
||||
const nextStep = currentStep + 1
|
||||
const success = await saveSettings({}, nextStep)
|
||||
|
||||
if (success) {
|
||||
setCurrentStep(nextStep)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = async () => {
|
||||
const success = await saveSettings({ onboarding_complete: true })
|
||||
|
||||
if (success) {
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
|
||||
|
||||
// Render light mode steps
|
||||
const renderLightSteps = () => (
|
||||
<>
|
||||
{currentStep === 1 && (
|
||||
<Step1EntityType
|
||||
initialData={{ entity_type: settings.entity_type as EntityType }}
|
||||
onNext={(data) => handleNext(data)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Step2LightPersonalInfo
|
||||
initialData={{
|
||||
company_name: settings.company_name ?? undefined,
|
||||
}}
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<Step3LightTaxProfile
|
||||
initialData={{
|
||||
municipality_code: settings.municipality_code ?? undefined,
|
||||
municipal_tax_rate: settings.municipal_tax_rate ?? undefined,
|
||||
church_tax: settings.church_tax ?? undefined,
|
||||
church_tax_rate: settings.church_tax_rate ?? undefined,
|
||||
church_parish_code: settings.church_parish_code ?? undefined,
|
||||
umbrella_provider: settings.umbrella_provider ?? undefined,
|
||||
umbrella_fee_percent: settings.umbrella_fee_percent ?? undefined,
|
||||
umbrella_pension_percent: settings.umbrella_pension_percent ?? undefined,
|
||||
umbrella_fee_custom: settings.umbrella_fee_custom ?? undefined,
|
||||
}}
|
||||
onNext={(data) => handleNext(data as Partial<CompanySettings>)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<Step6ConnectBank
|
||||
initialData={{
|
||||
bank_name: settings.bank_name ?? undefined,
|
||||
clearing_number: settings.clearing_number ?? undefined,
|
||||
account_number: settings.account_number ?? undefined,
|
||||
iban: settings.iban ?? undefined,
|
||||
bic: settings.bic ?? undefined,
|
||||
}}
|
||||
onComplete={async (data) => {
|
||||
if (data) {
|
||||
await saveSettings({ ...data, onboarding_complete: true })
|
||||
} else {
|
||||
await saveSettings({ onboarding_complete: true })
|
||||
}
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}}
|
||||
onBack={handleBack}
|
||||
onSkip={handleComplete}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
// Render EF/AB mode steps
|
||||
const renderEfAbSteps = () => (
|
||||
<>
|
||||
{currentStep === 1 && (
|
||||
<Step1EntityType
|
||||
initialData={{ entity_type: settings.entity_type as EntityType }}
|
||||
onNext={(data) => handleNext(data)}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Step2CompanyDetails
|
||||
initialData={{
|
||||
company_name: settings.company_name ?? undefined,
|
||||
org_number: settings.org_number ?? undefined,
|
||||
address_line1: settings.address_line1 ?? undefined,
|
||||
postal_code: settings.postal_code ?? undefined,
|
||||
city: settings.city ?? undefined,
|
||||
}}
|
||||
entityType={settings.entity_type as EntityType}
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<Step3TaxRegistration
|
||||
initialData={{
|
||||
f_skatt: settings.f_skatt ?? undefined,
|
||||
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
|
||||
vat_registered: settings.vat_registered ?? undefined,
|
||||
vat_number: settings.vat_number ?? undefined,
|
||||
moms_period: settings.moms_period as MomsPeriod | undefined,
|
||||
}}
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<Step4PreliminaryTax
|
||||
initialData={{
|
||||
preliminary_tax_monthly: settings.preliminary_tax_monthly ?? undefined,
|
||||
}}
|
||||
onNext={(data) => handleNext(data)}
|
||||
onBack={handleBack}
|
||||
onSkip={handleSkip}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 5 && (
|
||||
<Step6ConnectBank
|
||||
initialData={{
|
||||
bank_name: settings.bank_name ?? undefined,
|
||||
clearing_number: settings.clearing_number ?? undefined,
|
||||
account_number: settings.account_number ?? undefined,
|
||||
iban: settings.iban ?? undefined,
|
||||
bic: settings.bic ?? undefined,
|
||||
}}
|
||||
onComplete={async (data) => {
|
||||
if (data) {
|
||||
await saveSettings({ ...data, onboarding_complete: true })
|
||||
} else {
|
||||
await saveSettings({ onboarding_complete: true })
|
||||
}
|
||||
toast({
|
||||
title: 'Välkommen!',
|
||||
description: 'Din profil är nu redo.',
|
||||
})
|
||||
router.push('/')
|
||||
}}
|
||||
onBack={handleBack}
|
||||
onSkip={handleComplete}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b">
|
||||
<div className="max-w-2xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Steg {currentStep} av {totalSteps}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{stepTitles[currentStep - 1]}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||
{isLight ? renderLightSteps() : renderEfAbSteps()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, use } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface InvoiceData {
|
||||
invoiceNumber: string
|
||||
invoiceDate: string
|
||||
dueDate: string
|
||||
total: number
|
||||
currency: string
|
||||
customerName: string
|
||||
reminderLevel: number
|
||||
alreadyResponded: boolean
|
||||
previousResponse: 'marked_paid' | 'disputed' | null
|
||||
}
|
||||
|
||||
export default function InvoiceActionPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = use(params)
|
||||
|
||||
const [invoice, setInvoice] = useState<InvoiceData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoiceData()
|
||||
}, [token])
|
||||
|
||||
async function fetchInvoiceData() {
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/reminders/action?token=${token}`)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Kunde inte hämta fakturainformation')
|
||||
return
|
||||
}
|
||||
|
||||
setInvoice(data)
|
||||
} catch {
|
||||
setError('Ett fel uppstod. Försök igen senare.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(action: 'marked_paid' | 'disputed') {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/invoices/reminders/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, action })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error || 'Kunde inte spara ditt svar')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccessMessage(data.message)
|
||||
if (invoice) {
|
||||
setInvoice({ ...invoice, alreadyResponded: true, previousResponse: action })
|
||||
}
|
||||
} catch {
|
||||
setError('Ett fel uppstod. Försök igen senare.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Laddar...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !invoice) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Ogiltig länk</h2>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Already responded view
|
||||
if (invoice.alreadyResponded || successMessage) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white flex items-center justify-center p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold mb-2">Tack för ditt svar!</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{successMessage || (
|
||||
invoice.previousResponse === 'marked_paid'
|
||||
? 'Vi har noterat att du har betalat fakturan.'
|
||||
: 'Vi har noterat din invändning och kommer att kontakta dig.'
|
||||
)}
|
||||
</p>
|
||||
<div className="bg-muted rounded-lg p-4 text-left">
|
||||
<p className="text-sm text-muted-foreground">Faktura</p>
|
||||
<p className="font-medium">{invoice.invoiceNumber}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate days overdue
|
||||
const dueDate = new Date(invoice.dueDate)
|
||||
const now = new Date()
|
||||
const daysOverdue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white py-12 px-4">
|
||||
<div className="max-w-lg mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
Betalningspåminnelse
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Faktura {invoice.invoiceNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invoice Summary Card */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Fakturainformation
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Till: {invoice.customerName}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Fakturadatum</p>
|
||||
<p className="font-medium">{formatDate(invoice.invoiceDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förfallodatum</p>
|
||||
<p className="font-medium text-red-600">{formatDate(invoice.dueDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-100 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600 mb-1">
|
||||
Förfallen med {daysOverdue} dagar
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive text-sm p-3 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Vad vill du göra?</CardTitle>
|
||||
<CardDescription>
|
||||
Välj ett alternativ nedan
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button
|
||||
className="w-full justify-start h-auto py-4 px-4"
|
||||
variant="outline"
|
||||
onClick={() => handleAction('marked_paid')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<CheckCircle className="h-5 w-5 mr-3 text-green-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Jag har betalat</p>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
Betalningen är redan genomförd
|
||||
</p>
|
||||
</div>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 ml-auto animate-spin" />}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="w-full justify-start h-auto py-4 px-4"
|
||||
variant="outline"
|
||||
onClick={() => handleAction('disputed')}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<MessageSquare className="h-5 w-5 mr-3 text-orange-600" />
|
||||
<div className="text-left">
|
||||
<p className="font-medium">Kontakta avsändaren</p>
|
||||
<p className="text-sm text-muted-foreground font-normal">
|
||||
Jag har frågor eller invändningar
|
||||
</p>
|
||||
</div>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 ml-auto animate-spin" />}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer note */}
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Om du redan har betalat kan det ta några dagar innan betalningen registreras.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Enable Banking returns: ?code=XXX&state=user_id or ?error=XXX&error_description=YYY
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state') // This is the user_id we passed during authorization
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
// Redirect URL for success/error
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
// Handle errors from bank authorization
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
console.error('Bank authorization error:', errorMessage)
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings?bank_error=${encodeURIComponent(errorMessage)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
// Create session from authorization code
|
||||
const sessionData = await createSession(code)
|
||||
|
||||
// Extract data from session response
|
||||
const { session_id, accounts, access, aspsp } = sessionData
|
||||
const consentExpiresAt = access.valid_until
|
||||
|
||||
// Get balances for each account
|
||||
const accountsWithBalances: StoredAccount[] = await Promise.all(
|
||||
accounts.map(async (account: AccountInfo) => {
|
||||
try {
|
||||
const balance = await getAccountBalance(account.uid)
|
||||
return {
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
balance: balance.amount,
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.error(`Failed to get balance for account ${account.uid}:`, balanceError)
|
||||
return {
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
balance: undefined,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Find the pending connection for this user
|
||||
// We match by user_id (state) and status='pending'
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id')
|
||||
.eq('user_id', state)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('Could not find pending connection:', findError)
|
||||
// Create a new connection if no pending one exists
|
||||
const { error: insertError } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: state,
|
||||
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
|
||||
bank_name: aspsp.name,
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
throw new Error('Failed to create connection')
|
||||
}
|
||||
} else {
|
||||
// Update the pending connection with session data
|
||||
const { error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
|
||||
if (updateError) {
|
||||
throw new Error('Failed to update connection')
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user has completed onboarding to decide redirect target
|
||||
const { data: userSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('onboarding_complete')
|
||||
.eq('user_id', state)
|
||||
.single()
|
||||
|
||||
const redirectTarget = userSettings?.onboarding_complete
|
||||
? '/settings?bank_connected=true'
|
||||
: '/onboarding?bank_connected=true'
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
|
||||
} catch (error) {
|
||||
console.error('Bank callback error:', error)
|
||||
|
||||
// Try to update connection status to error
|
||||
try {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error' })
|
||||
.eq('user_id', state)
|
||||
.eq('status', 'pending')
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings?bank_error=${encodeURIComponent('Connection failed')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { startAuthorization, getASPSPs, type ASPSP } from '@/lib/banking/enable-banking'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
// Transform to frontend-friendly format
|
||||
const banks = aspsps.map((aspsp: ASPSP) => ({
|
||||
name: aspsp.name,
|
||||
country: aspsp.country,
|
||||
logo: aspsp.logo,
|
||||
bic: aspsp.bic,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ banks })
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return NextResponse.json({
|
||||
banks: [
|
||||
{ name: 'Nordea', country: 'SE', bic: 'NDEASESS' },
|
||||
{ name: 'SEB', country: 'SE', bic: 'ESSESESS' },
|
||||
{ name: 'Swedbank', country: 'SE', bic: 'SWEDSESS' },
|
||||
{ name: 'Handelsbanken', country: 'SE', bic: 'HANDSESS' },
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { aspsp_name, aspsp_country } = await request.json()
|
||||
|
||||
if (!aspsp_name || !aspsp_country) {
|
||||
return NextResponse.json(
|
||||
{ error: 'aspsp_name and aspsp_country are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/banking/callback`
|
||||
|
||||
// Start the authorization flow with Enable Banking
|
||||
const { url, authorization_id } = await startAuthorization(
|
||||
aspsp_name,
|
||||
aspsp_country,
|
||||
redirectUrl,
|
||||
user.id, // state parameter - returned in callback
|
||||
'personal'
|
||||
)
|
||||
|
||||
// Store pending connection in database with authorization_id
|
||||
// Note: session_id will be set after callback receives the code
|
||||
const { data: connection, error } = await supabase
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Database error:', error)
|
||||
throw new Error('Failed to store connection')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
connection_id: connection.id,
|
||||
authorization_url: url,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Bank connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Connection failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/lib/banking/enable-banking'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/banking/sync/cron
|
||||
* Automatic daily bank transaction sync
|
||||
* Runs at 05:00 UTC (07:00 Swedish time)
|
||||
*
|
||||
* Processes up to 10 connections per run (Vercel Hobby 60s timeout).
|
||||
* Prioritizes connections not synced for the longest time.
|
||||
* Deduplication via external_id makes repeated runs safe.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch active bank connections, prioritize least recently synced
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.order('last_synced_at', { ascending: true, nullsFirst: true })
|
||||
.limit(10)
|
||||
|
||||
if (connError) {
|
||||
console.error('Failed to fetch bank connections:', connError)
|
||||
return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
||||
}
|
||||
|
||||
const results: {
|
||||
connectionId: string
|
||||
userId: string
|
||||
bankName: string
|
||||
imported: number
|
||||
duplicates: number
|
||||
errors: number
|
||||
status: 'synced' | 'expired' | 'expiring_soon' | 'error'
|
||||
daysUntilExpiry?: number | null
|
||||
}[] = []
|
||||
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
// Check consent expiry
|
||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpired = daysLeft !== null && daysLeft <= 0
|
||||
|
||||
if (isExpired) {
|
||||
// Mark as expired, skip sync
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'expired' })
|
||||
.eq('id', connection.id)
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
status: 'expired',
|
||||
daysUntilExpiry: 0,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
|
||||
// Sync last 7 days (daily cron, with overlap for safety)
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
let totalErrors = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
connection.user_id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
totalErrors += result.errors
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
errors: totalErrors,
|
||||
status: expiringSoon ? 'expiring_soon' : 'synced',
|
||||
daysUntilExpiry: daysLeft,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Sync failed for connection ${connection.id}:`, error)
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
userId: connection.user_id,
|
||||
bankName: connection.bank_name,
|
||||
imported: 0,
|
||||
duplicates: 0,
|
||||
errors: 1,
|
||||
status: 'error',
|
||||
})
|
||||
// Continue with other connections
|
||||
}
|
||||
}
|
||||
|
||||
const totalImported = results.reduce((sum, r) => sum + r.imported, 0)
|
||||
const totalExpired = results.filter(r => r.status === 'expired').length
|
||||
const totalExpiringSoon = results.filter(r => r.status === 'expiring_soon').length
|
||||
const totalFailed = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(`[bank-sync-cron] Processed ${results.length} connections: ${totalImported} imported, ${totalExpired} expired, ${totalExpiringSoon} expiring soon, ${totalFailed} failed`)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: results.length,
|
||||
totalImported,
|
||||
totalExpired,
|
||||
totalExpiringSoon,
|
||||
totalFailed,
|
||||
results,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { syncAccountTransactions } from '@/lib/banking/sync-transactions'
|
||||
|
||||
interface StoredAccount {
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
currency: string
|
||||
balance?: number
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { connection_id, days_back = 30 } = await request.json()
|
||||
|
||||
// Get the bank connection
|
||||
const { data: connection, error: connectionError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('*')
|
||||
.eq('id', connection_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (connectionError || !connection) {
|
||||
return NextResponse.json({ error: 'Connection not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (connection.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Connection is not active' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
const result = await syncAccountTransactions(
|
||||
supabase,
|
||||
user.id,
|
||||
connection.id,
|
||||
account,
|
||||
fromDate,
|
||||
toDate
|
||||
)
|
||||
|
||||
totalImported += result.imported
|
||||
totalDuplicates += result.duplicates
|
||||
}
|
||||
|
||||
// Update connection with new account balances and sync timestamp
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
return NextResponse.json({
|
||||
imported: totalImported,
|
||||
duplicates: totalDuplicates,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Sync failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ number: string }> }
|
||||
) {
|
||||
const { number } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({
|
||||
account_name: body.account_name,
|
||||
is_active: body.is_active,
|
||||
description: body.description,
|
||||
default_vat_code: body.default_vat_code,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.eq('account_number', number)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const accountClass = searchParams.get('class')
|
||||
const activeOnly = searchParams.get('active') !== 'false'
|
||||
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('sort_order')
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
if (accountClass) {
|
||||
query = query.eq('account_class', parseInt(accountClass))
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
account_number: body.account_number,
|
||||
account_name: body.account_name,
|
||||
account_class: parseInt(body.account_number[0]),
|
||||
account_group: body.account_number.substring(0, 2),
|
||||
account_type: body.account_type,
|
||||
normal_balance: body.normal_balance,
|
||||
plan_type: body.plan_type || 'k1',
|
||||
is_system_account: false,
|
||||
description: body.description || null,
|
||||
sort_order: parseInt(body.account_number),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateFiscalPeriodInput } from '@/types'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json() as CreateFiscalPeriodInput
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.name,
|
||||
period_start: body.period_start,
|
||||
period_end: body.period_end,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const reversalEntry = await reverseEntry(user.id, id)
|
||||
return NextResponse.json({ data: reversalEntry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import type { CreateJournalEntryInput } from '@/types'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
const dateFrom = searchParams.get('date_from')
|
||||
const dateTo = searchParams.get('date_to')
|
||||
|
||||
let query = supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('entry_date', { ascending: false })
|
||||
.order('voucher_number', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (periodId) {
|
||||
query = query.eq('fiscal_period_id', periodId)
|
||||
}
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
query = query.gte('entry_date', dateFrom)
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
query = query.lte('entry_date', dateTo)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json() as CreateJournalEntryInput
|
||||
|
||||
try {
|
||||
const entry = await createJournalEntry(user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction } from '@/types'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Accept either a transaction ID or raw transaction data
|
||||
let transaction: Transaction
|
||||
|
||||
if (body.transaction_id) {
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', body.transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !data) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
transaction = data as Transaction
|
||||
} else {
|
||||
transaction = body as Transaction
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await evaluateMappingRules(user.id, transaction)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Evaluation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${user.id},user_id.is.null`)
|
||||
.eq('is_active', true)
|
||||
.order('priority')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('mapping_rules')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
rule_name: body.rule_name,
|
||||
rule_type: body.rule_type,
|
||||
priority: body.priority || 10,
|
||||
mcc_codes: body.mcc_codes || null,
|
||||
merchant_pattern: body.merchant_pattern || null,
|
||||
description_pattern: body.description_pattern || null,
|
||||
amount_min: body.amount_min || null,
|
||||
amount_max: body.amount_max || null,
|
||||
debit_account: body.debit_account,
|
||||
credit_account: body.credit_account,
|
||||
vat_treatment: body.vat_treatment || null,
|
||||
risk_level: body.risk_level || 'NONE',
|
||||
default_private: body.default_private || false,
|
||||
requires_review: body.requires_review || false,
|
||||
confidence_score: body.confidence_score || 0.9,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/briefings/[id]/download
|
||||
* Get a signed URL to download a PDF briefing
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the briefing
|
||||
const { data: briefing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('briefing_type, content, filename')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Verify it's a PDF type
|
||||
if (briefing.briefing_type !== 'pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Download is only available for PDF briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!briefing.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No file path found for this briefing' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create signed URL (valid for 1 hour)
|
||||
const { data: signedUrl, error: signError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.createSignedUrl(briefing.content, 3600, {
|
||||
download: briefing.filename || 'briefing.pdf',
|
||||
})
|
||||
|
||||
if (signError) {
|
||||
return NextResponse.json({ error: signError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: signedUrl.signedUrl })
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateBriefingInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/briefings/[id]
|
||||
* Get a single briefing
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/briefings/[id]
|
||||
* Update a briefing
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify briefing exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateBriefingInput> = await request.json()
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.content !== undefined) updateData.content = body.content
|
||||
if (body.text_content !== undefined) updateData.text_content = body.text_content
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Only allow updating certain fields for PDF type
|
||||
if (existing.briefing_type === 'pdf') {
|
||||
if (body.filename !== undefined) updateData.filename = body.filename
|
||||
if (body.file_size !== undefined) updateData.file_size = body.file_size
|
||||
if (body.mime_type !== undefined) updateData.mime_type = body.mime_type
|
||||
}
|
||||
|
||||
// Update the briefing
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/briefings/[id]
|
||||
* Delete a briefing
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the briefing to check if we need to delete a file
|
||||
const { data: briefing, error: fetchError } = await supabase
|
||||
.from('briefings')
|
||||
.select('briefing_type, content')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Briefing not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If it's a PDF, delete the file from storage
|
||||
if (briefing.briefing_type === 'pdf' && briefing.content) {
|
||||
await supabase.storage.from('contracts').remove([briefing.content])
|
||||
}
|
||||
|
||||
// Delete the briefing
|
||||
const { error } = await supabase
|
||||
.from('briefings')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import Anthropic from '@anthropic-ai/sdk'
|
||||
|
||||
const anthropic = new Anthropic()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { text } = await request.json()
|
||||
|
||||
if (!text || typeof text !== 'string') {
|
||||
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await anthropic.messages.create({
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Du är en assistent som hjälper influencers. Sammanfatta följande mailkonversation/text till en strukturerad briefing.
|
||||
|
||||
Formatera sammanfattningen med dessa rubriker (hoppa över de som inte nämns):
|
||||
- **Varumärke/Kund**: Vilken kund eller varumärke det gäller
|
||||
- **Vad ska göras**: Innehåll/publiceringar som förväntas
|
||||
- **Deadlines**: Datum och tidsramar
|
||||
- **Belopp**: Ersättning om nämnt
|
||||
- **Övriga detaljer**: Annat viktigt
|
||||
|
||||
Texten att sammanfatta:
|
||||
|
||||
${text}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const summary = message.content[0].type === 'text' ? message.content[0].text : ''
|
||||
|
||||
return NextResponse.json({ data: { summary } })
|
||||
} catch (error) {
|
||||
console.error('Briefing summarization error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to summarize briefing' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateCalendarFeed } from '@/lib/calendar/ics-generator'
|
||||
|
||||
/**
|
||||
* GET /api/calendar/feed/[token]
|
||||
* Returns an ICS calendar feed for the given token
|
||||
* No authentication required - the token IS the authentication
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> }
|
||||
) {
|
||||
const { token } = await params
|
||||
|
||||
// Validate token format (UUID)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
if (!uuidRegex.test(token)) {
|
||||
return new NextResponse('Invalid token', { status: 400 })
|
||||
}
|
||||
|
||||
// Create service client (no user auth required)
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return new NextResponse('Server configuration error', { status: 500 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch feed settings by token
|
||||
const { data: feed, error: feedError } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('*')
|
||||
.eq('feed_token', token)
|
||||
.eq('is_active', true)
|
||||
.single()
|
||||
|
||||
if (feedError || !feed) {
|
||||
return new NextResponse('Feed not found or inactive', { status: 404 })
|
||||
}
|
||||
|
||||
// Update access tracking
|
||||
await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
last_accessed_at: new Date().toISOString(),
|
||||
access_count: feed.access_count + 1,
|
||||
})
|
||||
.eq('id', feed.id)
|
||||
|
||||
// Calculate date range: 3 months back, 12 months forward
|
||||
const now = new Date()
|
||||
const startDate = new Date(now)
|
||||
startDate.setMonth(startDate.getMonth() - 3)
|
||||
const endDate = new Date(now)
|
||||
endDate.setMonth(endDate.getMonth() + 12)
|
||||
|
||||
const startStr = startDate.toISOString().split('T')[0]
|
||||
const endStr = endDate.toISOString().split('T')[0]
|
||||
|
||||
// Fetch relevant data based on feed options
|
||||
const [deadlinesResult, invoicesResult, campaignsResult, exclusivitiesResult] = await Promise.all([
|
||||
// Deadlines
|
||||
(feed.include_tax_deadlines || feed.include_campaigns)
|
||||
? supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
: { data: [] },
|
||||
|
||||
// Invoices
|
||||
feed.include_invoices
|
||||
? supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('due_date', startStr)
|
||||
.lte('due_date', endStr)
|
||||
.order('due_date')
|
||||
: { data: [] },
|
||||
|
||||
// Campaigns with deliverables
|
||||
feed.include_campaigns
|
||||
? supabase
|
||||
.from('campaigns')
|
||||
.select('*, deliverables(*)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.in('status', ['active', 'contracted', 'delivered'])
|
||||
: { data: [] },
|
||||
|
||||
// Exclusivities
|
||||
feed.include_exclusivity
|
||||
? supabase
|
||||
.from('exclusivities')
|
||||
.select('*, campaign:campaigns(name)')
|
||||
.eq('user_id', feed.user_id)
|
||||
.gte('end_date', startStr)
|
||||
.lte('start_date', endStr)
|
||||
: { data: [] },
|
||||
])
|
||||
|
||||
try {
|
||||
const icsContent = await generateCalendarFeed(
|
||||
{
|
||||
deadlines: deadlinesResult.data || [],
|
||||
invoices: invoicesResult.data || [],
|
||||
campaigns: campaignsResult.data || [],
|
||||
exclusivities: exclusivitiesResult.data || [],
|
||||
},
|
||||
{
|
||||
includeTaxDeadlines: feed.include_tax_deadlines,
|
||||
includeInvoices: feed.include_invoices,
|
||||
includeCampaigns: feed.include_campaigns,
|
||||
includeExclusivity: feed.include_exclusivity,
|
||||
}
|
||||
)
|
||||
|
||||
return new NextResponse(icsContent, {
|
||||
headers: {
|
||||
'Content-Type': 'text/calendar; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="influencer-biz.ics"',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating ICS feed:', error)
|
||||
return new NextResponse('Failed to generate calendar feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { UpdateCalendarFeedInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/calendar/feed
|
||||
* Get current user's calendar feed settings
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
// PGRST116 = no rows returned, which is fine
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Generate the feed URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
if (feed) {
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
// Generate webcal:// URL for Apple Calendar
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
// Generate https:// URL for other calendars
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: null })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/calendar/feed
|
||||
* Create a new calendar feed for the current user
|
||||
*/
|
||||
export async function POST() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if feed already exists
|
||||
const { data: existingFeed } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (existingFeed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Calendar feed already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create new feed
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
is_active: true,
|
||||
include_tax_deadlines: true,
|
||||
include_invoices: true,
|
||||
include_campaigns: true,
|
||||
include_exclusivity: false, // Avstängt som standard
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/calendar/feed
|
||||
* Update calendar feed settings
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: UpdateCalendarFeedInput = await request.json()
|
||||
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update(body)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/calendar/feed
|
||||
* Regenerate calendar feed token (invalidates old URL)
|
||||
*/
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Generate a new token by updating with a new UUID
|
||||
const { data: feed, error } = await supabase
|
||||
.from('calendar_feeds')
|
||||
.update({
|
||||
feed_token: crypto.randomUUID(),
|
||||
access_count: 0,
|
||||
last_accessed_at: null,
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://app.influencer-biz.se'
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...feed,
|
||||
webcalUrl: `webcal://${baseUrl.replace(/^https?:\/\//, '')}/api/calendar/feed/${feed.feed_token}`,
|
||||
httpsUrl: `${baseUrl}/api/calendar/feed/${feed.feed_token}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateBriefingInput, BriefingType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/briefings
|
||||
* List all briefings for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch briefings
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/briefings
|
||||
* Create a new briefing for a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateBriefingInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.briefing_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Title and briefing_type are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate briefing type
|
||||
const validTypes: BriefingType[] = ['pdf', 'link', 'text']
|
||||
if (!validTypes.includes(body.briefing_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid briefing_type. Must be pdf, link, or text' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate type-specific content
|
||||
if (body.briefing_type === 'text' && !body.text_content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'text_content is required for text type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (body.briefing_type === 'link' && !body.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content (URL) is required for link type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (body.briefing_type === 'pdf' && !body.content) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content (file path) is required for pdf type briefings' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create the briefing
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.insert({
|
||||
campaign_id: campaignId,
|
||||
user_id: user.id,
|
||||
briefing_type: body.briefing_type,
|
||||
title: body.title,
|
||||
content: body.content || null,
|
||||
text_content: body.text_content || null,
|
||||
filename: body.filename || null,
|
||||
file_size: body.file_size || null,
|
||||
mime_type: body.mime_type || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/briefings/upload
|
||||
* Upload a PDF briefing file
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the PDF file
|
||||
* - title: briefing title
|
||||
* - notes: optional notes
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const title = formData.get('title') as string | null
|
||||
const notes = formData.get('notes') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: 'Title is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type (only PDF)
|
||||
if (file.type !== 'application/pdf') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Only PDF files are allowed.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Max file size: 10MB
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json(
|
||||
{ error: 'File too large. Max size: 10MB' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate unique file path in contracts bucket (reusing existing bucket)
|
||||
// Path: {user_id}/{campaign_id}/briefings/{timestamp}_{filename}
|
||||
const timestamp = Date.now()
|
||||
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const filePath = `${user.id}/${campaignId}/briefings/${timestamp}_${safeFilename}`
|
||||
|
||||
// Upload to Supabase Storage (using contracts bucket)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create briefing record
|
||||
const { data, error } = await supabase
|
||||
.from('briefings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
briefing_type: 'pdf',
|
||||
title: title.trim(),
|
||||
content: filePath,
|
||||
filename: file.name,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
notes: notes?.trim() || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Try to clean up uploaded file
|
||||
await supabase.storage.from('contracts').remove([filePath])
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
} catch (err) {
|
||||
console.error('Briefing upload error:', err)
|
||||
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/contracts
|
||||
* List contracts for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('uploaded_at', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/contracts
|
||||
* Upload a contract to a campaign
|
||||
* Expects multipart/form-data with:
|
||||
* - file: the contract file
|
||||
* - signing_date: optional ISO date string
|
||||
* - is_primary: optional boolean
|
||||
* - notes: optional string
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const signingDate = formData.get('signing_date') as string | null
|
||||
const isPrimary = formData.get('is_primary') === 'true'
|
||||
const notes = formData.get('notes') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type (PDF, DOC, DOCX, images)
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
]
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return NextResponse.json({
|
||||
error: 'Invalid file type. Allowed: PDF, DOC, DOCX, JPG, PNG, WEBP'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Max file size: 10MB
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({ error: 'File too large. Max size: 10MB' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Generate unique file path
|
||||
const timestamp = Date.now()
|
||||
const safeFilename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||
const filePath = `${user.id}/${campaignId}/${timestamp}_${safeFilename}`
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.upload(filePath, file, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
console.error('Upload error:', uploadError)
|
||||
return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })
|
||||
}
|
||||
|
||||
// If this is set as primary, unset other primary contracts
|
||||
if (isPrimary) {
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ is_primary: false })
|
||||
.eq('campaign_id', campaignId)
|
||||
.eq('is_primary', true)
|
||||
}
|
||||
|
||||
// Create contract record
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
filename: file.name,
|
||||
file_path: filePath,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
signing_date: signingDate || null,
|
||||
is_primary: isPrimary,
|
||||
notes: notes || null,
|
||||
extraction_status: 'pending'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Try to clean up uploaded file
|
||||
await supabase.storage.from('contracts').remove([filePath])
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If contract is signed and this is primary, update campaign status
|
||||
if (isPrimary && signingDate) {
|
||||
await supabase
|
||||
.from('campaigns')
|
||||
.update({
|
||||
contract_signed_at: signingDate,
|
||||
status: 'contracted'
|
||||
})
|
||||
.eq('id', campaignId)
|
||||
.eq('status', 'negotiation')
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
console.error('Contract upload error:', err)
|
||||
return NextResponse.json({ error: 'Failed to process upload' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeliverableInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/deliverables
|
||||
* List deliverables for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('due_date', { ascending: true, nullsFirst: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/deliverables
|
||||
* Add a deliverable to a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateDeliverableInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.deliverable_type || !body.platform) {
|
||||
return NextResponse.json({ error: 'Missing required fields (title, deliverable_type, platform)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Insert the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
title: body.title,
|
||||
deliverable_type: body.deliverable_type,
|
||||
platform: body.platform,
|
||||
account_handle: body.account_handle || null,
|
||||
quantity: body.quantity || 1,
|
||||
description: body.description || null,
|
||||
specifications: body.specifications || {},
|
||||
due_date: body.due_date || null,
|
||||
status: 'pending',
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally auto-generate a deadline for this deliverable
|
||||
if (body.due_date) {
|
||||
await supabase.from('deadlines').insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
deliverable_id: data.id,
|
||||
title: `Leverans: ${body.title}`,
|
||||
due_date: body.due_date,
|
||||
deadline_type: 'delivery',
|
||||
priority: 'important',
|
||||
is_auto_generated: true,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateExclusivityInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]/exclusivities
|
||||
* List exclusivities for a campaign
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*')
|
||||
.eq('campaign_id', campaignId)
|
||||
.order('start_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/[id]/exclusivities
|
||||
* Add an exclusivity to a campaign
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: campaignId } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name')
|
||||
.eq('id', campaignId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (campaignError || !campaign) {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: Omit<CreateExclusivityInput, 'campaign_id'> = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.categories || body.categories.length === 0 || !body.start_date || !body.end_date) {
|
||||
return NextResponse.json({ error: 'Missing required fields (categories, start_date, end_date)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate date range
|
||||
if (new Date(body.end_date) < new Date(body.start_date)) {
|
||||
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for conflicts with existing exclusivities
|
||||
const { data: existingExclusivities } = await supabase
|
||||
.from('exclusivities')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.neq('campaign_id', campaignId)
|
||||
.lte('start_date', body.end_date)
|
||||
.gte('end_date', body.start_date)
|
||||
|
||||
const conflicts = []
|
||||
if (existingExclusivities) {
|
||||
for (const existing of existingExclusivities) {
|
||||
const overlappingCategories = body.categories.filter(cat =>
|
||||
existing.categories.some((existingCat: string) =>
|
||||
existingCat.toLowerCase() === cat.toLowerCase()
|
||||
)
|
||||
)
|
||||
|
||||
if (overlappingCategories.length > 0) {
|
||||
conflicts.push({
|
||||
exclusivity_id: existing.id,
|
||||
campaign_id: existing.campaign?.id,
|
||||
campaign_name: existing.campaign?.name,
|
||||
overlapping_categories: overlappingCategories,
|
||||
overlap_start: body.start_date > existing.start_date ? body.start_date : existing.start_date,
|
||||
overlap_end: body.end_date < existing.end_date ? body.end_date : existing.end_date
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the exclusivity
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
campaign_id: campaignId,
|
||||
categories: body.categories,
|
||||
excluded_brands: body.excluded_brands || [],
|
||||
start_date: body.start_date,
|
||||
end_date: body.end_date,
|
||||
start_calculation_type: body.start_calculation_type || 'absolute',
|
||||
end_calculation_type: body.end_calculation_type || 'absolute',
|
||||
start_reference: body.start_reference || null,
|
||||
end_reference: body.end_reference || null,
|
||||
start_offset_days: body.start_offset_days || null,
|
||||
end_offset_days: body.end_offset_days || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Return with conflict warnings if any
|
||||
return NextResponse.json({
|
||||
data,
|
||||
conflicts: conflicts.length > 0 ? conflicts : undefined,
|
||||
warning: conflicts.length > 0
|
||||
? `Varning: ${conflicts.length} överlappande exklusivitet(er) hittades`
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCampaignInput, CampaignStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/[id]
|
||||
* Get a single campaign with all related data
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name, email, customer_category, customer_type),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
|
||||
deliverables(
|
||||
id, title, description, deliverable_type, platform, account_handle,
|
||||
quantity, specifications, due_date, status, submitted_at, approved_at,
|
||||
published_at, notes, created_at
|
||||
),
|
||||
exclusivities(
|
||||
id, categories, excluded_brands, start_date, end_date,
|
||||
start_calculation_type, end_calculation_type, notes, created_at
|
||||
),
|
||||
contracts(
|
||||
id, filename, file_path, file_size, mime_type, signing_date,
|
||||
is_primary, extraction_status, notes, uploaded_at
|
||||
),
|
||||
briefings(
|
||||
id, briefing_type, title, content, text_content, filename,
|
||||
file_size, mime_type, notes, created_at, updated_at
|
||||
)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Also fetch related invoices
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
|
||||
.eq('campaign_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({ data: { ...data, invoices: invoices || [] } })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/campaigns/[id]
|
||||
* Update a campaign
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateCampaignInput> & { status?: CampaignStatus; contract_signed_at?: string } = await request.json()
|
||||
|
||||
// Verify campaign exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('campaigns')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Campaign not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Validate customer if changed
|
||||
if (body.customer_id && body.customer_id !== existing.customer_id) {
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.description !== undefined) updateData.description = body.description
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.end_customer_id !== undefined) updateData.end_customer_id = body.end_customer_id || null
|
||||
if (body.campaign_type !== undefined) updateData.campaign_type = body.campaign_type
|
||||
if (body.status !== undefined) updateData.status = body.status
|
||||
if (body.total_value !== undefined) updateData.total_value = body.total_value
|
||||
if (body.currency !== undefined) updateData.currency = body.currency
|
||||
if (body.vat_included !== undefined) updateData.vat_included = body.vat_included
|
||||
if (body.payment_terms !== undefined) updateData.payment_terms = body.payment_terms
|
||||
if (body.billing_frequency !== undefined) updateData.billing_frequency = body.billing_frequency
|
||||
if (body.brand_name !== undefined) updateData.brand_name = body.brand_name
|
||||
if (body.start_date !== undefined) updateData.start_date = body.start_date
|
||||
if (body.end_date !== undefined) updateData.end_date = body.end_date
|
||||
if (body.publication_date !== undefined) updateData.publication_date = body.publication_date
|
||||
if (body.draft_deadline !== undefined) updateData.draft_deadline = body.draft_deadline
|
||||
if (body.contract_signed_at !== undefined) updateData.contract_signed_at = body.contract_signed_at
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the campaign
|
||||
const { data, error } = await supabase
|
||||
.from('campaigns')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/campaigns/[id]
|
||||
* Delete a campaign (cascades to deliverables, exclusivities, contracts)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// First, delete any contract files from storage
|
||||
const { data: contracts } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path')
|
||||
.eq('campaign_id', id)
|
||||
|
||||
if (contracts && contracts.length > 0) {
|
||||
const filePaths = contracts.map(c => c.file_path)
|
||||
await supabase.storage.from('contracts').remove(filePaths)
|
||||
}
|
||||
|
||||
// Delete the campaign (cascades to related tables)
|
||||
const { error } = await supabase
|
||||
.from('campaigns')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { checkExclusivityConflicts } from '@/lib/campaigns/exclusivity-checker'
|
||||
import type {
|
||||
ContractExtractionResult,
|
||||
CreateCampaignInput,
|
||||
CreateDeliverableInput,
|
||||
CreateExclusivityInput,
|
||||
Exclusivity,
|
||||
} from '@/types'
|
||||
|
||||
interface CreateFromContractInput {
|
||||
contractId: string
|
||||
extraction: ContractExtractionResult
|
||||
customerId: string | null
|
||||
endCustomerId: string | null
|
||||
brandName?: string | null
|
||||
createNewCustomer?: {
|
||||
name: string
|
||||
org_number?: string
|
||||
email?: string
|
||||
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
|
||||
}
|
||||
createNewEndCustomer?: {
|
||||
name: string
|
||||
org_number?: string
|
||||
email?: string
|
||||
customer_type: 'individual' | 'swedish_business' | 'eu_business' | 'non_eu_business'
|
||||
}
|
||||
overrides?: Partial<CreateCampaignInput>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns/from-contract
|
||||
* Create a campaign from extracted contract data
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateFromContractInput = await request.json()
|
||||
const { contractId, extraction, overrides } = body
|
||||
let { customerId, endCustomerId } = body
|
||||
|
||||
// Verify contract exists and belongs to user
|
||||
const { data: contract, error: contractError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('id', contractId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (contractError) {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new customers if needed
|
||||
if (body.createNewCustomer && !customerId) {
|
||||
const { data: newCustomer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.createNewCustomer.name,
|
||||
org_number: body.createNewCustomer.org_number,
|
||||
email: body.createNewCustomer.email,
|
||||
customer_type: body.createNewCustomer.customer_type,
|
||||
country: 'Sweden',
|
||||
default_payment_terms: extraction.financials.paymentTerms || 30,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (customerError) {
|
||||
throw new Error(`Failed to create customer: ${customerError.message}`)
|
||||
}
|
||||
customerId = newCustomer.id
|
||||
}
|
||||
|
||||
if (body.createNewEndCustomer && !endCustomerId) {
|
||||
const { data: newEndCustomer, error: endCustomerError } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.createNewEndCustomer.name,
|
||||
org_number: body.createNewEndCustomer.org_number,
|
||||
email: body.createNewEndCustomer.email,
|
||||
customer_type: body.createNewEndCustomer.customer_type,
|
||||
country: 'Sweden',
|
||||
default_payment_terms: 30,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (endCustomerError) {
|
||||
throw new Error(`Failed to create end customer: ${endCustomerError.message}`)
|
||||
}
|
||||
endCustomerId = newEndCustomer.id
|
||||
}
|
||||
|
||||
// Create campaign
|
||||
const campaignData: CreateCampaignInput = {
|
||||
customer_id: customerId || undefined,
|
||||
end_customer_id: endCustomerId || undefined,
|
||||
name: extraction.campaignName || `Samarbete ${new Date().toLocaleDateString('sv-SE')}`,
|
||||
brand_name: body.brandName || extraction.parties.brand?.name || undefined,
|
||||
campaign_type: 'influencer',
|
||||
total_value: extraction.financials.amount || undefined,
|
||||
currency: extraction.financials.currency || 'SEK',
|
||||
vat_included: extraction.financials.vatIncluded ?? false,
|
||||
payment_terms: extraction.financials.paymentTerms || undefined,
|
||||
billing_frequency: extraction.financials.billingFrequency || undefined,
|
||||
start_date: extraction.period.startDate || undefined,
|
||||
end_date: extraction.period.endDate || undefined,
|
||||
publication_date: extraction.period.publicationDate || undefined,
|
||||
draft_deadline: (extraction.period as Record<string, unknown>).draftDeadline as string || undefined,
|
||||
...overrides,
|
||||
}
|
||||
|
||||
const { data: campaign, error: campaignError } = await supabase
|
||||
.from('campaigns')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...campaignData,
|
||||
status: 'contracted',
|
||||
contract_signed_at: extraction.signingDate || new Date().toISOString().split('T')[0],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (campaignError) {
|
||||
throw new Error(`Failed to create campaign: ${campaignError.message}`)
|
||||
}
|
||||
|
||||
// Link contract to campaign
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
campaign_id: campaign.id,
|
||||
is_primary: true,
|
||||
extraction_status: 'completed',
|
||||
})
|
||||
.eq('id', contractId)
|
||||
|
||||
// Create deliverables
|
||||
const deliverables: { id: string }[] = []
|
||||
for (const del of extraction.deliverables) {
|
||||
const deliverableData: CreateDeliverableInput = {
|
||||
campaign_id: campaign.id,
|
||||
title: del.description || `${del.type} - ${del.platform || 'Okänd plattform'}`,
|
||||
deliverable_type: del.type,
|
||||
platform: del.platform || 'instagram',
|
||||
account_handle: del.account || undefined,
|
||||
quantity: del.quantity,
|
||||
due_date: del.dueDate || undefined,
|
||||
}
|
||||
|
||||
const { data: deliverable, error: deliverableError } = await supabase
|
||||
.from('deliverables')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...deliverableData,
|
||||
status: 'pending',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (deliverableError) {
|
||||
console.error('Failed to create deliverable:', deliverableError)
|
||||
continue
|
||||
}
|
||||
deliverables.push(deliverable)
|
||||
}
|
||||
|
||||
// Create exclusivity if present
|
||||
let exclusivityConflicts: unknown[] = []
|
||||
if (extraction.exclusivity.categories.length > 0) {
|
||||
// Calculate exclusivity dates
|
||||
const exclusivityStart = extraction.period.startDate ||
|
||||
new Date().toISOString().split('T')[0]
|
||||
|
||||
let exclusivityEnd = extraction.period.endDate ||
|
||||
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
// Extend end date if post period specified
|
||||
if (extraction.exclusivity.postPeriodDays) {
|
||||
const endDate = new Date(exclusivityEnd)
|
||||
endDate.setDate(endDate.getDate() + extraction.exclusivity.postPeriodDays)
|
||||
exclusivityEnd = endDate.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
// Check for conflicts
|
||||
const { data: existingExclusivities } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*, campaign:campaigns(*)')
|
||||
.eq('user_id', user.id)
|
||||
.neq('campaign_id', campaign.id)
|
||||
|
||||
if (existingExclusivities) {
|
||||
exclusivityConflicts = checkExclusivityConflicts(
|
||||
{
|
||||
categories: extraction.exclusivity.categories,
|
||||
start_date: exclusivityStart,
|
||||
end_date: exclusivityEnd,
|
||||
} as Exclusivity,
|
||||
existingExclusivities as Exclusivity[]
|
||||
)
|
||||
}
|
||||
|
||||
const exclusivityData: CreateExclusivityInput = {
|
||||
campaign_id: campaign.id,
|
||||
categories: extraction.exclusivity.categories,
|
||||
excluded_brands: extraction.exclusivity.excludedBrands,
|
||||
start_date: exclusivityStart,
|
||||
end_date: exclusivityEnd,
|
||||
start_calculation_type: 'absolute',
|
||||
end_calculation_type: extraction.exclusivity.postReference ? 'relative' : 'absolute',
|
||||
end_reference: extraction.exclusivity.postReference || undefined,
|
||||
end_offset_days: extraction.exclusivity.postPeriodDays || undefined,
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('exclusivities')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
...exclusivityData,
|
||||
})
|
||||
}
|
||||
|
||||
// Create deadlines
|
||||
const createdDeadlines: unknown[] = []
|
||||
for (const deadline of extraction.deadlines) {
|
||||
let dueDate = deadline.absoluteDate
|
||||
|
||||
// Calculate relative dates if possible
|
||||
if (!dueDate && deadline.isRelative && deadline.referenceEvent && deadline.offsetDays) {
|
||||
let referenceDate: string | null = null
|
||||
|
||||
switch (deadline.referenceEvent) {
|
||||
case 'publication':
|
||||
referenceDate = extraction.period.publicationDate
|
||||
break
|
||||
case 'delivery':
|
||||
referenceDate = extraction.period.endDate
|
||||
break
|
||||
case 'contract':
|
||||
referenceDate = extraction.signingDate
|
||||
break
|
||||
}
|
||||
|
||||
if (referenceDate) {
|
||||
const refDate = new Date(referenceDate)
|
||||
refDate.setDate(refDate.getDate() + deadline.offsetDays)
|
||||
dueDate = refDate.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we still don't have a date
|
||||
if (!dueDate) continue
|
||||
|
||||
const { data: createdDeadline, error: deadlineError } = await supabase
|
||||
.from('deadlines')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: deadline.description,
|
||||
due_date: dueDate,
|
||||
deadline_type: deadline.type,
|
||||
priority: 'normal',
|
||||
customer_id: customerId,
|
||||
campaign_id: campaign.id,
|
||||
is_auto_generated: true,
|
||||
date_calculation_type: deadline.isRelative ? 'relative' : 'absolute',
|
||||
reference_event: deadline.referenceEvent,
|
||||
offset_days: deadline.offsetDays,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!deadlineError && createdDeadline) {
|
||||
createdDeadlines.push(createdDeadline)
|
||||
}
|
||||
}
|
||||
|
||||
// Also create standard campaign deadlines
|
||||
if (campaign.end_date) {
|
||||
// Invoicing deadline (5 days after end)
|
||||
const invoiceDate = new Date(campaign.end_date)
|
||||
invoiceDate.setDate(invoiceDate.getDate() + 5)
|
||||
|
||||
await supabase.from('deadlines').insert({
|
||||
user_id: user.id,
|
||||
title: `Fakturera: ${campaign.name}`,
|
||||
due_date: invoiceDate.toISOString().split('T')[0],
|
||||
deadline_type: 'invoicing',
|
||||
priority: 'important',
|
||||
customer_id: customerId,
|
||||
campaign_id: campaign.id,
|
||||
is_auto_generated: true,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
campaign,
|
||||
deliverablesCreated: deliverables.length,
|
||||
deadlinesCreated: createdDeadlines.length,
|
||||
exclusivityConflicts,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create campaign'
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCampaignInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/campaigns
|
||||
* List campaigns for the authenticated user
|
||||
* Query params:
|
||||
* - status: CampaignStatus (optional, comma-separated for multiple)
|
||||
* - type: CampaignType (optional)
|
||||
* - customer_id: string (optional)
|
||||
* - from: ISO date string (optional, start_date >=)
|
||||
* - to: ISO date string (optional, end_date <=)
|
||||
* - limit: number (default: 50)
|
||||
* - offset: number (default: 0)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const type = searchParams.get('type')
|
||||
const customerId = searchParams.get('customer_id')
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
// Build query with relations
|
||||
let query = supabase
|
||||
.from('campaigns')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name, customer_category),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name),
|
||||
deliverables(id, title, status, due_date, platform, deliverable_type),
|
||||
exclusivities(id, categories, start_date, end_date),
|
||||
contracts(id, filename, is_primary)
|
||||
`, { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Apply filters
|
||||
if (status) {
|
||||
const statuses = status.split(',')
|
||||
if (statuses.length === 1) {
|
||||
query = query.eq('status', status)
|
||||
} else {
|
||||
query = query.in('status', statuses)
|
||||
}
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query = query.eq('campaign_type', type)
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
query = query.eq('customer_id', customerId)
|
||||
}
|
||||
|
||||
if (from) {
|
||||
query = query.gte('start_date', from)
|
||||
}
|
||||
|
||||
if (to) {
|
||||
query = query.lte('end_date', to)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/campaigns
|
||||
* Create a new campaign
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateCampaignInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name) {
|
||||
return NextResponse.json({ error: 'Campaign name is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate customer exists if provided
|
||||
if (body.customer_id) {
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate end_customer exists if provided
|
||||
if (body.end_customer_id) {
|
||||
const { data: endCustomer, error: endCustomerError } = await supabase
|
||||
.from('customers')
|
||||
.select('id')
|
||||
.eq('id', body.end_customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (endCustomerError || !endCustomer) {
|
||||
return NextResponse.json({ error: 'End customer not found' }, { status: 404 })
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the campaign
|
||||
const { data, error } = await supabase
|
||||
.from('campaigns')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: body.customer_id || null,
|
||||
end_customer_id: body.end_customer_id || null,
|
||||
name: body.name,
|
||||
description: body.description || null,
|
||||
brand_name: body.brand_name || null,
|
||||
campaign_type: body.campaign_type || 'influencer',
|
||||
status: 'negotiation',
|
||||
total_value: body.total_value || null,
|
||||
currency: body.currency || 'SEK',
|
||||
vat_included: body.vat_included || false,
|
||||
payment_terms: body.payment_terms || null,
|
||||
billing_frequency: body.billing_frequency || null,
|
||||
start_date: body.start_date || null,
|
||||
end_date: body.end_date || null,
|
||||
publication_date: body.publication_date || null,
|
||||
draft_deadline: body.draft_deadline || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select(`
|
||||
*,
|
||||
customer:customers!campaigns_customer_id_fkey(id, name),
|
||||
end_customer:customers!campaigns_end_customer_id_fkey(id, name)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { PlatformType, DeliverableType, Deliverable } from '@/types'
|
||||
|
||||
interface WorkloadDay {
|
||||
date: string
|
||||
deliverables: Deliverable[]
|
||||
totalDeliverables: number
|
||||
byPlatform: Partial<Record<PlatformType, number>>
|
||||
byType: Partial<Record<DeliverableType, number>>
|
||||
workloadLevel: 'light' | 'normal' | 'heavy' | 'overloaded'
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/campaigns/workload
|
||||
* Get workload analysis for deliverables
|
||||
* Query params:
|
||||
* - from: ISO date string (default: today)
|
||||
* - to: ISO date string (default: 30 days from now)
|
||||
* - group_by: 'day' | 'week' (default: 'day')
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const from = searchParams.get('from') || new Date().toISOString().split('T')[0]
|
||||
const defaultTo = new Date()
|
||||
defaultTo.setDate(defaultTo.getDate() + 30)
|
||||
const to = searchParams.get('to') || defaultTo.toISOString().split('T')[0]
|
||||
const groupBy = searchParams.get('group_by') || 'day'
|
||||
|
||||
// Fetch deliverables with due dates in range
|
||||
const { data: deliverables, error } = await supabase
|
||||
.from('deliverables')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.gte('due_date', from)
|
||||
.lte('due_date', to)
|
||||
.not('status', 'in', '("approved","published")')
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Group deliverables by date
|
||||
const workloadByDate: Record<string, WorkloadDay> = {}
|
||||
|
||||
for (const deliverable of deliverables || []) {
|
||||
if (!deliverable.due_date) continue
|
||||
|
||||
let dateKey = deliverable.due_date
|
||||
|
||||
// If grouping by week, use the Monday of that week
|
||||
if (groupBy === 'week') {
|
||||
const date = new Date(deliverable.due_date)
|
||||
const day = date.getDay()
|
||||
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
|
||||
date.setDate(diff)
|
||||
dateKey = date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
if (!workloadByDate[dateKey]) {
|
||||
workloadByDate[dateKey] = {
|
||||
date: dateKey,
|
||||
deliverables: [],
|
||||
totalDeliverables: 0,
|
||||
byPlatform: {},
|
||||
byType: {},
|
||||
workloadLevel: 'light'
|
||||
}
|
||||
}
|
||||
|
||||
const day = workloadByDate[dateKey]
|
||||
day.deliverables.push(deliverable)
|
||||
day.totalDeliverables += deliverable.quantity || 1
|
||||
|
||||
// Count by platform
|
||||
const platform = deliverable.platform as PlatformType
|
||||
day.byPlatform[platform] = (day.byPlatform[platform] || 0) + (deliverable.quantity || 1)
|
||||
|
||||
// Count by type
|
||||
const type = deliverable.deliverable_type as DeliverableType
|
||||
day.byType[type] = (day.byType[type] || 0) + (deliverable.quantity || 1)
|
||||
}
|
||||
|
||||
// Calculate workload levels
|
||||
for (const day of Object.values(workloadByDate)) {
|
||||
if (groupBy === 'week') {
|
||||
// Weekly thresholds
|
||||
if (day.totalDeliverables <= 3) day.workloadLevel = 'light'
|
||||
else if (day.totalDeliverables <= 7) day.workloadLevel = 'normal'
|
||||
else if (day.totalDeliverables <= 12) day.workloadLevel = 'heavy'
|
||||
else day.workloadLevel = 'overloaded'
|
||||
} else {
|
||||
// Daily thresholds
|
||||
if (day.totalDeliverables <= 1) day.workloadLevel = 'light'
|
||||
else if (day.totalDeliverables <= 2) day.workloadLevel = 'normal'
|
||||
else if (day.totalDeliverables <= 4) day.workloadLevel = 'heavy'
|
||||
else day.workloadLevel = 'overloaded'
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
const workload = Object.values(workloadByDate).sort((a, b) =>
|
||||
a.date.localeCompare(b.date)
|
||||
)
|
||||
|
||||
// Calculate summary stats
|
||||
const totalDeliverables = deliverables?.reduce((sum, d) => sum + (d.quantity || 1), 0) || 0
|
||||
const heavyDays = workload.filter(d => d.workloadLevel === 'heavy').length
|
||||
const overloadedDays = workload.filter(d => d.workloadLevel === 'overloaded').length
|
||||
|
||||
// Find busiest day/week
|
||||
const busiestPeriod = workload.length > 0
|
||||
? workload.reduce((max, day) => day.totalDeliverables > max.totalDeliverables ? day : max)
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
workload,
|
||||
summary: {
|
||||
period: { from, to, group_by: groupBy },
|
||||
total_deliverables: totalDeliverables,
|
||||
total_periods: workload.length,
|
||||
heavy_periods: heavyDays,
|
||||
overloaded_periods: overloadedDays,
|
||||
busiest_period: busiestPeriod ? {
|
||||
date: busiestPeriod.date,
|
||||
count: busiestPeriod.totalDeliverables
|
||||
} : null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateChatResponse } from '@/lib/ai/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000, // 1 minute window
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Send a message and get a response
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please wait a moment.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Message is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100), // Use first 100 chars of message as title
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
console.error('Error creating session:', sessionError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create chat session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
const { data: userMessage, error: userMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (userMsgError) {
|
||||
console.error('Error saving user message:', userMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save message' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Generate AI response
|
||||
const result = await generateChatResponse(message.trim(), conversationHistory)
|
||||
|
||||
// Save assistant message
|
||||
const { data: assistantMessage, error: assistantMsgError } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
sources: result.sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (assistantMsgError) {
|
||||
console.error('Error saving assistant message:', assistantMsgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save response' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: assistantMessage,
|
||||
session_id: sessionId,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Chat error:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to process chat' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions/[id]
|
||||
* Get a single chat session with its messages
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Get session with messages
|
||||
const { data: session, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (sessionError || !session) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get messages
|
||||
const { data: messages, error: messagesError } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('*')
|
||||
.eq('session_id', id)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (messagesError) {
|
||||
console.error('Error fetching messages:', messagesError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch messages' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
session,
|
||||
messages: messages || [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/chat/sessions/[id]
|
||||
* Update a chat session (e.g., rename)
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.update({ title })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Session not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to update session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/chat/sessions/[id]
|
||||
* Delete a chat session and its messages
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Delete session (messages will cascade delete due to FK)
|
||||
const { error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to delete session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions
|
||||
* List all chat sessions for the authenticated user
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching sessions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch sessions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/sessions
|
||||
* Create a new chat session
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { title } = body
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: title || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating session:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create session' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create session' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { streamChatResponse } from '@/lib/ai/chatbot/chain'
|
||||
import { CHATBOT_CONFIG } from '@/lib/ai/chatbot/config'
|
||||
import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat'
|
||||
|
||||
// Simple in-memory rate limiting (per user)
|
||||
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
function checkRateLimit(userId: string): boolean {
|
||||
const now = Date.now()
|
||||
const limit = rateLimitMap.get(userId)
|
||||
|
||||
if (!limit || now > limit.resetTime) {
|
||||
rateLimitMap.set(userId, {
|
||||
count: 1,
|
||||
resetTime: now + 60000,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
if (limit.count >= CHATBOT_CONFIG.rateLimitPerMinute) {
|
||||
return false
|
||||
}
|
||||
|
||||
limit.count++
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/chat/stream
|
||||
* Streaming chat response via Server-Sent Events
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Unauthorized' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
if (!checkRateLimit(user.id)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const body: ChatRequest = await request.json()
|
||||
const { message, session_id } = body
|
||||
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Message is required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
let sessionId = session_id
|
||||
|
||||
// Create new session if not provided
|
||||
if (!sessionId) {
|
||||
const { data: newSession, error: sessionError } = await supabase
|
||||
.from('chat_sessions')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: message.slice(0, 100),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sessionError) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to create session' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
sessionId = newSession.id
|
||||
} else {
|
||||
// Verify session belongs to user
|
||||
const { data: existingSession } = await supabase
|
||||
.from('chat_sessions')
|
||||
.select('id')
|
||||
.eq('id', sessionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existingSession) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Session not found' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save user message
|
||||
await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'user',
|
||||
content: message.trim(),
|
||||
sources: [],
|
||||
})
|
||||
|
||||
// Get conversation history
|
||||
const { data: history } = await supabase
|
||||
.from('chat_messages')
|
||||
.select('role, content')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(CHATBOT_CONFIG.maxHistoryMessages)
|
||||
|
||||
const conversationHistory = (history || []) as ChatMessage[]
|
||||
|
||||
// Create streaming response
|
||||
const encoder = new TextEncoder()
|
||||
let fullContent = ''
|
||||
let sources: SourceReference[] = []
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
// Send session ID first
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'session', session_id: sessionId })}\n\n`)
|
||||
)
|
||||
|
||||
// Stream the response
|
||||
for await (const chunk of streamChatResponse(message.trim(), conversationHistory)) {
|
||||
if (chunk.type === 'content') {
|
||||
fullContent += chunk.data as string
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'content', content: chunk.data })}\n\n`)
|
||||
)
|
||||
} else if (chunk.type === 'sources') {
|
||||
sources = chunk.data as SourceReference[]
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'sources', sources: chunk.data })}\n\n`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the complete assistant message
|
||||
const { data: savedMessage } = await supabase
|
||||
.from('chat_messages')
|
||||
.insert({
|
||||
session_id: sessionId,
|
||||
user_id: user.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
sources,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
// Send done signal with message ID
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', message_id: savedMessage?.id })}\n\n`)
|
||||
)
|
||||
|
||||
controller.close()
|
||||
} catch (error) {
|
||||
console.error('Streaming error:', error)
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', error: 'Streaming failed' })}\n\n`)
|
||||
)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Stream setup error:', err)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to setup stream' }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]/download
|
||||
* Download a contract file
|
||||
* Returns a signed URL for direct download
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get contract to verify ownership and get file path
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path, filename, mime_type')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create signed URL (valid for 1 hour)
|
||||
const { data: signedUrl, error: signError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.createSignedUrl(contract.file_path, 3600, {
|
||||
download: contract.filename
|
||||
})
|
||||
|
||||
if (signError || !signedUrl) {
|
||||
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
url: signedUrl.signedUrl,
|
||||
filename: contract.filename,
|
||||
mime_type: contract.mime_type
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { pdfToBase64, getPDFInfo } from '@/lib/contracts/pdf-extractor'
|
||||
import { analyzeContract } from '@/lib/contracts/contract-analyzer'
|
||||
import { matchParties } from '@/lib/customers/customer-matcher'
|
||||
import type { ContractExtractionResult, Customer } from '@/types'
|
||||
|
||||
// Rate limiting map (in production, use Redis or similar)
|
||||
const extractionTimestamps = new Map<string, number[]>()
|
||||
const RATE_LIMIT = 10 // Max extractions per minute
|
||||
const RATE_WINDOW_MS = 60 * 1000 // 1 minute
|
||||
|
||||
/**
|
||||
* POST /api/contracts/[id]/extract
|
||||
* Extract and analyze contract content using AI
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
// Authenticate user
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const now = Date.now()
|
||||
const userTimestamps = extractionTimestamps.get(user.id) || []
|
||||
const recentTimestamps = userTimestamps.filter((t) => now - t < RATE_WINDOW_MS)
|
||||
|
||||
if (recentTimestamps.length >= RATE_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
// Record this extraction attempt
|
||||
recentTimestamps.push(now)
|
||||
extractionTimestamps.set(user.id, recentTimestamps)
|
||||
|
||||
// Get contract
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Check if already extracted
|
||||
if (contract.extraction_status === 'completed' && contract.extracted_data) {
|
||||
// Return cached result
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const extraction = contract.extracted_data as ContractExtractionResult
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
extraction,
|
||||
customerMatches: matches,
|
||||
cached: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Update status to processing
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ extraction_status: 'processing' })
|
||||
.eq('id', id)
|
||||
|
||||
try {
|
||||
console.log('[Extract] Starting extraction for contract:', id)
|
||||
console.log('[Extract] File path:', contract.file_path)
|
||||
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('contracts')
|
||||
.download(contract.file_path)
|
||||
|
||||
if (downloadError) {
|
||||
console.error('[Extract] Download error:', downloadError)
|
||||
throw new Error(`Failed to download contract: ${downloadError.message}`)
|
||||
}
|
||||
|
||||
console.log('[Extract] File downloaded, size:', fileData.size)
|
||||
|
||||
// Check file type
|
||||
if (contract.mime_type !== 'application/pdf') {
|
||||
throw new Error('Only PDF files are supported for extraction')
|
||||
}
|
||||
|
||||
// Convert PDF to base64 for Claude
|
||||
console.log('[Extract] Converting PDF to base64...')
|
||||
const buffer = Buffer.from(await fileData.arrayBuffer())
|
||||
const pdfInfo = getPDFInfo(buffer)
|
||||
|
||||
if (!pdfInfo.isValidSize) {
|
||||
throw new Error(`PDF is too large (${pdfInfo.sizeMB.toFixed(1)}MB). Maximum size is 32MB.`)
|
||||
}
|
||||
|
||||
const pdfBase64 = pdfToBase64(buffer)
|
||||
console.log('[Extract] PDF converted, size:', pdfInfo.sizeMB.toFixed(2), 'MB')
|
||||
|
||||
// Analyze with Claude AI (sending PDF directly)
|
||||
console.log('[Extract] Analyzing with Claude AI...')
|
||||
const extraction = await analyzeContract(pdfBase64)
|
||||
console.log('[Extract] Analysis complete')
|
||||
|
||||
// Get customers for matching
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
// Save extraction result
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
extracted_data: extraction as unknown as Record<string, unknown>,
|
||||
extraction_status: 'completed',
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
extraction,
|
||||
customerMatches: matches,
|
||||
cached: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Extract] Error:', error)
|
||||
|
||||
// Update status to failed
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({
|
||||
extraction_status: 'failed',
|
||||
extracted_data: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
const message = error instanceof Error ? error.message : 'Extraction failed'
|
||||
console.error('[Extract] Returning error:', message)
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]/extract
|
||||
* Get extraction status and result
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data: contract, error } = await supabase
|
||||
.from('contracts')
|
||||
.select('extraction_status, extracted_data')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If completed, also return customer matches
|
||||
if (contract.extraction_status === 'completed' && contract.extracted_data) {
|
||||
const customers = await fetchUserCustomers(supabase, user.id)
|
||||
const extraction = contract.extracted_data as ContractExtractionResult
|
||||
const matches = matchParties(
|
||||
extraction.parties.brand,
|
||||
extraction.parties.agency,
|
||||
customers
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
status: contract.extraction_status,
|
||||
extraction: contract.extracted_data,
|
||||
customerMatches: matches,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
status: contract.extraction_status,
|
||||
extraction: contract.extracted_data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Helper to fetch user's customers
|
||||
async function fetchUserCustomers(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string
|
||||
): Promise<Customer[]> {
|
||||
const { data } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('name')
|
||||
|
||||
return data || []
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/contracts/[id]
|
||||
* Get a single contract
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.select('*, campaign:campaigns(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/contracts/[id]
|
||||
* Update contract metadata
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: {
|
||||
signing_date?: string
|
||||
is_primary?: boolean
|
||||
notes?: string
|
||||
} = await request.json()
|
||||
|
||||
// Verify contract exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('*, campaign_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If setting as primary, unset other primary contracts
|
||||
if (body.is_primary === true) {
|
||||
await supabase
|
||||
.from('contracts')
|
||||
.update({ is_primary: false })
|
||||
.eq('campaign_id', existing.campaign_id)
|
||||
.eq('is_primary', true)
|
||||
.neq('id', id)
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.signing_date !== undefined) updateData.signing_date = body.signing_date
|
||||
if (body.is_primary !== undefined) updateData.is_primary = body.is_primary
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the contract
|
||||
const { data, error } = await supabase
|
||||
.from('contracts')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/contracts/[id]
|
||||
* Delete a contract and its file
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get contract to find file path
|
||||
const { data: contract, error: fetchError } = await supabase
|
||||
.from('contracts')
|
||||
.select('file_path')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Contract not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Delete file from storage
|
||||
if (contract.file_path) {
|
||||
await supabase.storage.from('contracts').remove([contract.file_path])
|
||||
}
|
||||
|
||||
// Delete contract record
|
||||
const { error } = await supabase
|
||||
.from('contracts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch related campaigns
|
||||
const { data: campaigns } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, name, status, total_value, currency, publication_date, brand_name')
|
||||
.eq('customer_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
// Fetch related invoices
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, status, total, currency, payment_status')
|
||||
.eq('customer_id', id)
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...data,
|
||||
campaigns: campaigns || [],
|
||||
invoices: invoices || [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateCustomerInput> = await request.json()
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.name !== undefined) updateData.name = body.name
|
||||
if (body.customer_type !== undefined) updateData.customer_type = body.customer_type
|
||||
if (body.email !== undefined) updateData.email = body.email
|
||||
if (body.phone !== undefined) updateData.phone = body.phone
|
||||
if (body.address_line1 !== undefined) updateData.address_line1 = body.address_line1
|
||||
if (body.address_line2 !== undefined) updateData.address_line2 = body.address_line2
|
||||
if (body.postal_code !== undefined) updateData.postal_code = body.postal_code
|
||||
if (body.city !== undefined) updateData.city = body.city
|
||||
if (body.country !== undefined) updateData.country = body.country
|
||||
if (body.org_number !== undefined) updateData.org_number = body.org_number
|
||||
if (body.vat_number !== undefined) updateData.vat_number = body.vat_number
|
||||
if (body.default_payment_terms !== undefined) updateData.default_payment_terms = body.default_payment_terms
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('customers')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateCustomerInput } from '@/types'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateCustomerInput = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('customers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.name,
|
||||
customer_type: body.customer_type,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address_line1: body.address_line1,
|
||||
address_line2: body.address_line2,
|
||||
postal_code: body.postal_code,
|
||||
city: body.city,
|
||||
country: body.country || 'Sweden',
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
notes: body.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/deadlines/[id]/complete
|
||||
* Toggle completion status of a deadline
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// First, get current deadline state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('is_completed')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Toggle completion
|
||||
const newCompletedState = !existing.is_completed
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: newCompletedState,
|
||||
completed_at: newCompletedState ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]
|
||||
* Get a single deadline by ID
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/deadlines/[id]
|
||||
* Update a deadline
|
||||
*/
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateDeadlineInput> = await request.json()
|
||||
|
||||
// First, get existing deadline to verify ownership
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deadlines')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.due_time !== undefined) updateData.due_time = body.due_time
|
||||
if (body.deadline_type !== undefined) updateData.deadline_type = body.deadline_type
|
||||
if (body.priority !== undefined) updateData.priority = body.priority
|
||||
if (body.customer_id !== undefined) updateData.customer_id = body.customer_id || null
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the deadline
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/deadlines/[id]
|
||||
* Delete a deadline
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { updateDeadlineStatus, isValidTransition } from '@/lib/deadlines/status-engine'
|
||||
import type { DeadlineStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* PATCH /api/deadlines/[id]/status
|
||||
* Manually update a deadline's status
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const body = await request.json()
|
||||
const newStatus = body.status as DeadlineStatus
|
||||
|
||||
if (!newStatus) {
|
||||
return NextResponse.json({ error: 'Status is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
if (!validStatuses.includes(newStatus)) {
|
||||
return NextResponse.json({ error: 'Invalid status' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await updateDeadlineStatus(supabase, id, user.id, newStatus)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/[id]/status
|
||||
* Get current status and valid transitions
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const { data: deadline, error } = await supabase
|
||||
.from('deadlines')
|
||||
.select('status, is_completed, due_date')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !deadline) {
|
||||
return NextResponse.json({ error: 'Deadline not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate valid transitions from current status
|
||||
const validTransitions: DeadlineStatus[] = []
|
||||
const allStatuses: DeadlineStatus[] = [
|
||||
'upcoming',
|
||||
'action_needed',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'confirmed',
|
||||
'overdue',
|
||||
]
|
||||
|
||||
for (const status of allStatuses) {
|
||||
if (isValidTransition(deadline.status, status)) {
|
||||
validTransitions.push(status)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
currentStatus: deadline.status,
|
||||
isCompleted: deadline.is_completed,
|
||||
dueDate: deadline.due_date,
|
||||
validTransitions,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeadlineInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines
|
||||
* List deadlines for the authenticated user
|
||||
* Query params:
|
||||
* - status: 'all' | 'pending' | 'completed' (default: 'all')
|
||||
* - type: DeadlineType (optional)
|
||||
* - from: ISO date string (optional)
|
||||
* - to: ISO date string (optional)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status') || 'all'
|
||||
const type = searchParams.get('type')
|
||||
const from = searchParams.get('from')
|
||||
const to = searchParams.get('to')
|
||||
|
||||
// Build query
|
||||
let query = supabase
|
||||
.from('deadlines')
|
||||
.select('*, customer:customers(id, name)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Apply filters
|
||||
if (status === 'pending') {
|
||||
query = query.eq('is_completed', false)
|
||||
} else if (status === 'completed') {
|
||||
query = query.eq('is_completed', true)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
query = query.eq('deadline_type', type)
|
||||
}
|
||||
|
||||
if (from) {
|
||||
query = query.gte('due_date', from)
|
||||
}
|
||||
|
||||
if (to) {
|
||||
query = query.lte('due_date', to)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('due_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/deadlines
|
||||
* Create a new deadline
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateDeadlineInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.due_date || !body.deadline_type) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Insert the deadline
|
||||
const { data, error } = await supabase
|
||||
.from('deadlines')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
title: body.title,
|
||||
due_date: body.due_date,
|
||||
due_time: body.due_time || null,
|
||||
deadline_type: body.deadline_type,
|
||||
priority: body.priority || 'normal',
|
||||
customer_id: body.customer_id || null,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select('*, customer:customers(id, name)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { updateDeadlineStatuses } from '@/lib/deadlines/status-engine'
|
||||
|
||||
/**
|
||||
* GET /api/deadlines/status/cron
|
||||
* Daily cron job to update deadline statuses
|
||||
* Runs at 06:00 every day
|
||||
*
|
||||
* Vercel Cron: "0 6 * * *"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Create a service role client for accessing all user data
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
try {
|
||||
const result = await updateDeadlineStatuses(supabase)
|
||||
|
||||
console.log(
|
||||
`Deadline status cron completed: ${result.updated} updated, ` +
|
||||
`${result.newlyOverdue} newly overdue, ${result.newlyActionNeeded} newly action_needed`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: result.updated,
|
||||
newlyOverdue: result.newlyOverdue,
|
||||
newlyActionNeeded: result.newlyActionNeeded,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in deadline status cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update deadline statuses' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateDeliverableInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/deliverables/[id]
|
||||
* Get a single deliverable
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*, campaign:campaigns(id, name, customer_id)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/deliverables/[id]
|
||||
* Update a deliverable
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<Omit<CreateDeliverableInput, 'campaign_id'>> = await request.json()
|
||||
|
||||
// Verify deliverable exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.title !== undefined) updateData.title = body.title
|
||||
if (body.deliverable_type !== undefined) updateData.deliverable_type = body.deliverable_type
|
||||
if (body.platform !== undefined) updateData.platform = body.platform
|
||||
if (body.account_handle !== undefined) updateData.account_handle = body.account_handle
|
||||
if (body.quantity !== undefined) updateData.quantity = body.quantity
|
||||
if (body.description !== undefined) updateData.description = body.description
|
||||
if (body.specifications !== undefined) updateData.specifications = body.specifications
|
||||
if (body.due_date !== undefined) updateData.due_date = body.due_date
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Update the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update auto-generated deadline if due_date changed
|
||||
if (body.due_date !== undefined) {
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({ due_date: body.due_date })
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_auto_generated', true)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/deliverables/[id]
|
||||
* Delete a deliverable
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Delete auto-generated deadlines first
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.delete()
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_auto_generated', true)
|
||||
|
||||
// Delete the deliverable
|
||||
const { error } = await supabase
|
||||
.from('deliverables')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { DeliverableStatus } from '@/types'
|
||||
|
||||
const VALID_STATUSES: DeliverableStatus[] = [
|
||||
'pending',
|
||||
'in_progress',
|
||||
'submitted',
|
||||
'revision',
|
||||
'approved',
|
||||
'published'
|
||||
]
|
||||
|
||||
/**
|
||||
* PATCH /api/deliverables/[id]/status
|
||||
* Update deliverable status with automatic timestamp tracking
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: { status: DeliverableStatus } = await request.json()
|
||||
|
||||
// Validate status
|
||||
if (!body.status || !VALID_STATUSES.includes(body.status)) {
|
||||
return NextResponse.json({
|
||||
error: `Invalid status. Must be one of: ${VALID_STATUSES.join(', ')}`
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify deliverable exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('deliverables')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Deliverable not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object with automatic timestamps
|
||||
const now = new Date().toISOString()
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: body.status
|
||||
}
|
||||
|
||||
// Set appropriate timestamp based on status
|
||||
if (body.status === 'submitted' && !existing.submitted_at) {
|
||||
updateData.submitted_at = now
|
||||
} else if (body.status === 'approved' && !existing.approved_at) {
|
||||
updateData.approved_at = now
|
||||
} else if (body.status === 'published' && !existing.published_at) {
|
||||
updateData.published_at = now
|
||||
}
|
||||
|
||||
// Update the deliverable
|
||||
const { data, error } = await supabase
|
||||
.from('deliverables')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// If status is now 'approved' or 'published', mark related deadline as completed
|
||||
if (body.status === 'approved' || body.status === 'published') {
|
||||
await supabase
|
||||
.from('deadlines')
|
||||
.update({
|
||||
is_completed: true,
|
||||
completed_at: now
|
||||
})
|
||||
.eq('deliverable_id', id)
|
||||
.eq('is_completed', false)
|
||||
}
|
||||
|
||||
// Check if all deliverables are completed - if so, maybe update campaign status
|
||||
if (body.status === 'published' || body.status === 'approved') {
|
||||
const { data: campaign } = await supabase
|
||||
.from('campaigns')
|
||||
.select('id, status')
|
||||
.eq('id', existing.campaign_id)
|
||||
.single()
|
||||
|
||||
if (campaign && campaign.status === 'active') {
|
||||
// Check if all deliverables are done
|
||||
const { data: allDeliverables } = await supabase
|
||||
.from('deliverables')
|
||||
.select('status')
|
||||
.eq('campaign_id', existing.campaign_id)
|
||||
|
||||
const allDone = allDeliverables?.every(d =>
|
||||
d.status === 'approved' || d.status === 'published'
|
||||
)
|
||||
|
||||
if (allDone) {
|
||||
await supabase
|
||||
.from('campaigns')
|
||||
.update({ status: 'delivered' })
|
||||
.eq('id', existing.campaign_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateExclusivityInput } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/exclusivities/[id]
|
||||
* Get a single exclusivity
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*, campaign:campaigns(id, name, customer_id)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/exclusivities/[id]
|
||||
* Update an exclusivity
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<Omit<CreateExclusivityInput, 'campaign_id'>> = await request.json()
|
||||
|
||||
// Verify exclusivity exists and belongs to user
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('exclusivities')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Exclusivity not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
if (body.categories !== undefined) updateData.categories = body.categories
|
||||
if (body.excluded_brands !== undefined) updateData.excluded_brands = body.excluded_brands
|
||||
if (body.start_date !== undefined) updateData.start_date = body.start_date
|
||||
if (body.end_date !== undefined) updateData.end_date = body.end_date
|
||||
if (body.start_calculation_type !== undefined) updateData.start_calculation_type = body.start_calculation_type
|
||||
if (body.end_calculation_type !== undefined) updateData.end_calculation_type = body.end_calculation_type
|
||||
if (body.start_reference !== undefined) updateData.start_reference = body.start_reference
|
||||
if (body.end_reference !== undefined) updateData.end_reference = body.end_reference
|
||||
if (body.start_offset_days !== undefined) updateData.start_offset_days = body.start_offset_days
|
||||
if (body.end_offset_days !== undefined) updateData.end_offset_days = body.end_offset_days
|
||||
if (body.notes !== undefined) updateData.notes = body.notes
|
||||
|
||||
// Validate date range if dates are being updated
|
||||
const startDate = body.start_date ?? existing.start_date
|
||||
const endDate = body.end_date ?? existing.end_date
|
||||
if (new Date(endDate) < new Date(startDate)) {
|
||||
return NextResponse.json({ error: 'End date must be after start date' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Update the exclusivity
|
||||
const { data, error } = await supabase
|
||||
.from('exclusivities')
|
||||
.update(updateData)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/exclusivities/[id]
|
||||
* Delete an exclusivity
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('exclusivities')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/exclusivities/conflicts
|
||||
* Check for exclusivity conflicts
|
||||
* Query params:
|
||||
* - categories: comma-separated list of categories to check
|
||||
* - start_date: ISO date string
|
||||
* - end_date: ISO date string
|
||||
* - exclude_campaign_id: campaign to exclude from check (optional)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const categoriesParam = searchParams.get('categories')
|
||||
const startDate = searchParams.get('start_date')
|
||||
const endDate = searchParams.get('end_date')
|
||||
const excludeCampaignId = searchParams.get('exclude_campaign_id')
|
||||
|
||||
if (!categoriesParam || !startDate || !endDate) {
|
||||
return NextResponse.json({
|
||||
error: 'Missing required parameters: categories, start_date, end_date'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
const categories = categoriesParam.split(',').map(c => c.trim().toLowerCase())
|
||||
|
||||
// Find overlapping exclusivities
|
||||
let query = supabase
|
||||
.from('exclusivities')
|
||||
.select(`
|
||||
*,
|
||||
campaign:campaigns(id, name, customer_id, customer:customers(id, name))
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.lte('start_date', endDate)
|
||||
.gte('end_date', startDate)
|
||||
|
||||
if (excludeCampaignId) {
|
||||
query = query.neq('campaign_id', excludeCampaignId)
|
||||
}
|
||||
|
||||
const { data: existingExclusivities, error } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find conflicts
|
||||
const conflicts = []
|
||||
if (existingExclusivities) {
|
||||
for (const existing of existingExclusivities) {
|
||||
const overlappingCategories = categories.filter(cat =>
|
||||
existing.categories.some((existingCat: string) =>
|
||||
existingCat.toLowerCase() === cat
|
||||
)
|
||||
)
|
||||
|
||||
if (overlappingCategories.length > 0) {
|
||||
// Calculate exact overlap period
|
||||
const overlapStart = startDate > existing.start_date ? startDate : existing.start_date
|
||||
const overlapEnd = endDate < existing.end_date ? endDate : existing.end_date
|
||||
|
||||
conflicts.push({
|
||||
exclusivity_id: existing.id,
|
||||
campaign_id: existing.campaign?.id,
|
||||
campaign_name: existing.campaign?.name,
|
||||
customer_name: existing.campaign?.customer?.name,
|
||||
categories: existing.categories,
|
||||
overlapping_categories: overlappingCategories,
|
||||
exclusivity_start: existing.start_date,
|
||||
exclusivity_end: existing.end_date,
|
||||
overlap_start: overlapStart,
|
||||
overlap_end: overlapEnd,
|
||||
overlap_days: Math.ceil(
|
||||
(new Date(overlapEnd).getTime() - new Date(overlapStart).getTime()) / (1000 * 60 * 60 * 24)
|
||||
) + 1
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
has_conflicts: conflicts.length > 0,
|
||||
conflicts,
|
||||
checked: {
|
||||
categories,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { classifyGift } from '@/lib/benefits/gift-classifier'
|
||||
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
|
||||
import type { CreateGiftInput, GiftInput, Gift } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts/[id]
|
||||
* Get a single gift by ID
|
||||
*/
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/gifts/[id]
|
||||
* Update a gift with re-classification
|
||||
*/
|
||||
export async function PUT(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateGiftInput> = await request.json()
|
||||
|
||||
// First, get existing gift to merge with updates
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError) {
|
||||
if (fetchError.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Gift not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Merge existing with updates
|
||||
const merged = {
|
||||
date: body.date ?? existing.date,
|
||||
brand_name: body.brand_name ?? existing.brand_name,
|
||||
description: body.description ?? existing.description,
|
||||
estimated_value: body.estimated_value ?? existing.estimated_value,
|
||||
has_motprestation: body.has_motprestation ?? existing.has_motprestation,
|
||||
used_in_business: body.used_in_business ?? existing.used_in_business,
|
||||
used_privately: body.used_privately ?? existing.used_privately,
|
||||
is_simple_promo: body.is_simple_promo ?? existing.is_simple_promo,
|
||||
}
|
||||
|
||||
// Re-classify with updated values
|
||||
const classificationInput: GiftInput = {
|
||||
estimatedValue: merged.estimated_value,
|
||||
hasMotprestation: merged.has_motprestation,
|
||||
usedInBusiness: merged.used_in_business,
|
||||
usedPrivately: merged.used_privately,
|
||||
isSimplePromoItem: merged.is_simple_promo,
|
||||
}
|
||||
const classification = classifyGift(classificationInput)
|
||||
|
||||
// Update the gift
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.update({
|
||||
...merged,
|
||||
classification,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Handle journal entry update
|
||||
// If classification changed to/from taxable, we may need to create/update entry
|
||||
const wasBookable = existing.classification?.taxable && existing.journal_entry_id
|
||||
const isBookable = classification.taxable
|
||||
|
||||
if (isBookable && !existing.journal_entry_id) {
|
||||
// Need to create a new journal entry
|
||||
try {
|
||||
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('gifts')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
data.journal_entry_id = journalEntry.id
|
||||
}
|
||||
} catch (bookingError) {
|
||||
console.error('Failed to create gift journal entry:', bookingError)
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Gåvan uppdaterades men bokföring kunde inte skapas.',
|
||||
})
|
||||
}
|
||||
} else if (isBookable && existing.journal_entry_id) {
|
||||
// Classification changed but still taxable - add warning that old entry may need reversal
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Klassificeringen ändrades. Den befintliga verifikationen kan behöva makuleras manuellt.',
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/gifts/[id]
|
||||
* Delete a gift
|
||||
*/
|
||||
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('gifts').delete().eq('id', id).eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { estimateProductValue } from '@/lib/receipts/receipt-analyzer'
|
||||
|
||||
/**
|
||||
* POST /api/gifts/estimate
|
||||
* Lightweight endpoint: accepts an image, returns AI price estimate.
|
||||
* Does NOT upload to storage, classify, or write to DB.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const imageFile = formData.get('image') as File | null
|
||||
|
||||
if (!imageFile) {
|
||||
return NextResponse.json({ error: 'Image is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
if (!validTypes.includes(imageFile.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Supported: JPEG, PNG, WebP, GIF' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const arrayBuffer = await imageFile.arrayBuffer()
|
||||
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||
const mimeType = imageFile.type as 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif'
|
||||
|
||||
const estimation = await estimateProductValue(base64, mimeType)
|
||||
|
||||
return NextResponse.json({ data: estimation })
|
||||
} catch (error) {
|
||||
console.error('Gift estimation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Estimation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { classifyGift, classifyGiftForEntity } from '@/lib/benefits/gift-classifier'
|
||||
import { createGiftJournalEntry } from '@/lib/benefits/gift-booking'
|
||||
import { calculateGiftVirtualTaxDebt } from '@/lib/tax/light-calculator'
|
||||
import type { CreateGiftInput, GiftInput, Gift, EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts
|
||||
* List gifts for the authenticated user
|
||||
* Query params: year (optional, defaults to current year)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const year = searchParams.get('year') || new Date().getFullYear().toString()
|
||||
|
||||
// Build date range for the year
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/gifts
|
||||
* Create a new gift with auto-classification
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateGiftInput = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.date || !body.brand_name || !body.description || body.estimated_value === undefined) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch entity type and tax settings
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, municipal_tax_rate, church_tax, church_tax_rate')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Build classification input
|
||||
const classificationInput: GiftInput = {
|
||||
estimatedValue: body.estimated_value,
|
||||
hasMotprestation: body.has_motprestation,
|
||||
usedInBusiness: body.used_in_business,
|
||||
usedPrivately: body.used_privately,
|
||||
isSimplePromoItem: body.is_simple_promo || false,
|
||||
}
|
||||
|
||||
// Classify the gift using entity-type-aware classifier
|
||||
const classification = classifyGiftForEntity(classificationInput, entityType)
|
||||
|
||||
// Insert the gift with classification
|
||||
const { data, error } = await supabase
|
||||
.from('gifts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: body.date,
|
||||
brand_name: body.brand_name,
|
||||
description: body.description,
|
||||
estimated_value: body.estimated_value,
|
||||
has_motprestation: body.has_motprestation,
|
||||
used_in_business: body.used_in_business,
|
||||
used_privately: body.used_privately,
|
||||
is_simple_promo: body.is_simple_promo || false,
|
||||
classification,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (entityType === 'light') {
|
||||
// Light mode: create shadow_ledger_entry instead of journal entry
|
||||
if (classification.taxable && data) {
|
||||
try {
|
||||
const municipalRate = Number(settings?.municipal_tax_rate) || 0.3238
|
||||
const churchRate = settings?.church_tax ? (Number(settings?.church_tax_rate) || 0.01) : 0
|
||||
const virtualTaxDebt = calculateGiftVirtualTaxDebt(
|
||||
body.estimated_value,
|
||||
municipalRate,
|
||||
churchRate
|
||||
)
|
||||
|
||||
await supabase
|
||||
.from('shadow_ledger_entries')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
date: body.date,
|
||||
type: 'gift',
|
||||
source: 'manual',
|
||||
gross_amount: body.estimated_value,
|
||||
net_amount: body.estimated_value,
|
||||
description: `Gåva: ${body.description} (${body.brand_name})`,
|
||||
gift_id: data.id,
|
||||
virtual_tax_debt: virtualTaxDebt,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to create shadow ledger entry for gift:', err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// EF/AB mode: create journal entry for taxable gifts
|
||||
let journalEntryId: string | null = null
|
||||
if (classification.taxable && data) {
|
||||
try {
|
||||
const journalEntry = await createGiftJournalEntry(user.id, data as Gift)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
|
||||
// Update gift with journal entry reference
|
||||
await supabase
|
||||
.from('gifts')
|
||||
.update({ journal_entry_id: journalEntryId })
|
||||
.eq('id', data.id)
|
||||
|
||||
// Update the returned data
|
||||
data.journal_entry_id = journalEntryId
|
||||
}
|
||||
} catch (bookingError) {
|
||||
console.error('Failed to create gift journal entry:', bookingError)
|
||||
return NextResponse.json({
|
||||
data,
|
||||
warning: 'Gåvan sparades men bokföring kunde inte skapas. Kontrollera att räkenskapsår finns.',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { Gift, GiftSummary } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/gifts/summary
|
||||
* Get gift summary for a year (used in dashboard and reports)
|
||||
* Query params: year (optional, defaults to current year)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const yearParam = searchParams.get('year')
|
||||
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear()
|
||||
|
||||
// Build date range for the year
|
||||
const startDate = `${year}-01-01`
|
||||
const endDate = `${year}-12-31`
|
||||
|
||||
const { data: gifts, error } = await supabase
|
||||
.from('gifts')
|
||||
.select('estimated_value, classification')
|
||||
.eq('user_id', user.id)
|
||||
.gte('date', startDate)
|
||||
.lte('date', endDate)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate summary
|
||||
const summary: GiftSummary = {
|
||||
year,
|
||||
total_count: gifts.length,
|
||||
total_value: 0,
|
||||
taxable_count: 0,
|
||||
taxable_value: 0,
|
||||
tax_free_count: 0,
|
||||
tax_free_value: 0,
|
||||
deductible_count: 0,
|
||||
deductible_value: 0,
|
||||
}
|
||||
|
||||
for (const gift of gifts as Pick<Gift, 'estimated_value' | 'classification'>[]) {
|
||||
const value = Number(gift.estimated_value)
|
||||
const classification = gift.classification
|
||||
|
||||
summary.total_value += value
|
||||
|
||||
if (classification?.taxable) {
|
||||
summary.taxable_count++
|
||||
summary.taxable_value += value
|
||||
} else {
|
||||
summary.tax_free_count++
|
||||
summary.tax_free_value += value
|
||||
}
|
||||
|
||||
if (classification?.deductibleAsExpense) {
|
||||
summary.deductible_count++
|
||||
summary.deductible_value += value
|
||||
}
|
||||
}
|
||||
|
||||
// Round values to 2 decimal places
|
||||
summary.total_value = Math.round(summary.total_value * 100) / 100
|
||||
summary.taxable_value = Math.round(summary.taxable_value * 100) / 100
|
||||
summary.tax_free_value = Math.round(summary.tax_free_value * 100) / 100
|
||||
summary.deductible_value = Math.round(summary.deductible_value * 100) / 100
|
||||
|
||||
return NextResponse.json({ data: summary })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/[id]
|
||||
* Get details of a specific SIE import
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('sie_imports')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return NextResponse.json({ error: 'Import not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/[id]
|
||||
* Delete an import record (does not delete created journal entries)
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('sie_imports')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { SIEAccount } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* Determine account type based on account class (first digit)
|
||||
*/
|
||||
function getAccountType(accountNumber: string): 'asset' | 'equity' | 'liability' | 'revenue' | 'expense' {
|
||||
const firstDigit = parseInt(accountNumber.charAt(0), 10)
|
||||
|
||||
switch (firstDigit) {
|
||||
case 1:
|
||||
return 'asset'
|
||||
case 2:
|
||||
// 20xx-20xx is equity, 21xx-29xx is liability
|
||||
const group = parseInt(accountNumber.substring(0, 2), 10)
|
||||
return group <= 20 ? 'equity' : 'liability'
|
||||
case 3:
|
||||
return 'revenue'
|
||||
case 4:
|
||||
case 5:
|
||||
case 6:
|
||||
case 7:
|
||||
return 'expense'
|
||||
case 8:
|
||||
// 8xxx can be either revenue (83xx interest income) or expense
|
||||
const subGroup = parseInt(accountNumber.substring(0, 2), 10)
|
||||
return subGroup >= 83 && subGroup <= 84 ? 'revenue' : 'expense'
|
||||
default:
|
||||
return 'expense'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine normal balance based on account type
|
||||
*/
|
||||
function getNormalBalance(accountType: string): 'debit' | 'credit' {
|
||||
switch (accountType) {
|
||||
case 'asset':
|
||||
case 'expense':
|
||||
return 'debit'
|
||||
case 'equity':
|
||||
case 'liability':
|
||||
case 'revenue':
|
||||
return 'credit'
|
||||
default:
|
||||
return 'debit'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/create-accounts
|
||||
* Create missing accounts from SIE file definitions
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const accounts: SIEAccount[] = body.accounts
|
||||
|
||||
if (!accounts || !Array.isArray(accounts) || accounts.length === 0) {
|
||||
return NextResponse.json({ error: 'No accounts provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch existing accounts to avoid duplicates
|
||||
const { data: existingAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const existingNumbers = new Set(existingAccounts?.map(a => a.account_number) || [])
|
||||
|
||||
// Filter to only accounts that don't exist
|
||||
const newAccounts = accounts.filter(a => !existingNumbers.has(a.number))
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
message: 'All accounts already exist'
|
||||
})
|
||||
}
|
||||
|
||||
// Prepare accounts for insertion
|
||||
const accountsToInsert = newAccounts.map(account => {
|
||||
const accountClass = parseInt(account.number.charAt(0), 10) || 1
|
||||
const accountGroup = account.number.substring(0, 2)
|
||||
const accountType = getAccountType(account.number)
|
||||
const normalBalance = getNormalBalance(accountType)
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
account_number: account.number,
|
||||
account_name: account.name,
|
||||
account_class: accountClass,
|
||||
account_group: accountGroup,
|
||||
account_type: accountType,
|
||||
normal_balance: normalBalance,
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
is_system_account: false, // User-created via import
|
||||
sort_order: parseInt(account.number, 10) || 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Insert in batches of 100 to avoid timeout
|
||||
const batchSize = 100
|
||||
let totalCreated = 0
|
||||
|
||||
for (let i = 0; i < accountsToInsert.length; i += batchSize) {
|
||||
const batch = accountsToInsert.slice(i, i + batchSize)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(batch)
|
||||
|
||||
if (error) {
|
||||
console.error('Error inserting accounts batch:', error)
|
||||
return NextResponse.json({
|
||||
error: `Failed to create accounts: ${error.message}`,
|
||||
created: totalCreated,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
totalCreated += batch.length
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: totalCreated,
|
||||
message: `Created ${totalCreated} new accounts`,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create accounts error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create accounts' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
||||
import { suggestMappings } from '@/lib/import/account-mapper'
|
||||
import { executeSIEImport } from '@/lib/import/sie-import'
|
||||
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/execute
|
||||
* Execute the SIE import
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data with file and options
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const mappingsJson = formData.get('mappings') as string | null
|
||||
const optionsJson = formData.get('options') as string | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const options = optionsJson ? JSON.parse(optionsJson) : {
|
||||
createFiscalPeriod: true,
|
||||
importOpeningBalances: true,
|
||||
importTransactions: true,
|
||||
voucherSeries: 'B',
|
||||
}
|
||||
|
||||
// Read and decode file
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const encoding = detectEncoding(arrayBuffer)
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Get mappings - either from request or generate new ones
|
||||
let mappings: AccountMapping[]
|
||||
|
||||
if (mappingsJson) {
|
||||
mappings = JSON.parse(mappingsJson)
|
||||
} else {
|
||||
// Fetch user's chart of accounts and generate mappings
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Load stored mappings
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Validate all accounts are mapped
|
||||
const unmapped = mappings.filter((m) => !m.targetAccount)
|
||||
if (unmapped.length > 0) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: `${unmapped.length} account(s) are not mapped`,
|
||||
unmappedAccounts: unmapped.map((m) => ({
|
||||
account: m.sourceAccount,
|
||||
name: m.sourceName,
|
||||
})),
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Execute the import
|
||||
const result = await executeSIEImport(
|
||||
user.id,
|
||||
parsed,
|
||||
mappings,
|
||||
{
|
||||
filename: file.name,
|
||||
fileContent: content,
|
||||
createFiscalPeriod: options.createFiscalPeriod,
|
||||
importOpeningBalances: options.importOpeningBalances,
|
||||
importTransactions: options.importTransactions,
|
||||
voucherSeries: options.voucherSeries || 'B',
|
||||
}
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({
|
||||
error: 'import',
|
||||
message: 'Import completed with errors',
|
||||
result,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE import error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to import SIE file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { saveMappings } from '@/lib/import/sie-import'
|
||||
import type { AccountMapping } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie/mappings
|
||||
* Get all saved account mappings for the user
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('source_account')
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/mappings
|
||||
* Save account mappings (bulk upsert)
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const mappings: AccountMapping[] = body.mappings
|
||||
|
||||
if (!mappings || !Array.isArray(mappings)) {
|
||||
return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
await saveMappings(user.id, mappings)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save mappings' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/import/sie/mappings
|
||||
* Update a single mapping
|
||||
*/
|
||||
export async function PUT(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { sourceAccount, targetAccount } = body
|
||||
|
||||
if (!sourceAccount || !targetAccount) {
|
||||
return NextResponse.json(
|
||||
{ error: 'sourceAccount and targetAccount are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.upsert({
|
||||
user_id: user.id,
|
||||
source_account: sourceAccount,
|
||||
target_account: targetAccount,
|
||||
confidence: 1.0,
|
||||
match_type: 'manual',
|
||||
}, {
|
||||
onConflict: 'user_id,source_account',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/import/sie/mappings
|
||||
* Delete a specific mapping or all mappings
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sourceAccount = searchParams.get('sourceAccount')
|
||||
|
||||
if (sourceAccount) {
|
||||
// Delete specific mapping
|
||||
const { error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('source_account', sourceAccount)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
} else {
|
||||
// Delete all mappings
|
||||
const { error } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
parseSIEFile,
|
||||
validateSIEFile,
|
||||
detectEncoding,
|
||||
decodeBuffer,
|
||||
calculateFileHash,
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import type { SIEAccountMappingRecord, SIEAccount } from '@/lib/import/types'
|
||||
|
||||
/**
|
||||
* POST /api/import/sie/parse
|
||||
* Parse an uploaded SIE file and return preview data
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data with file
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const filename = file.name.toLowerCase()
|
||||
if (!filename.endsWith('.sie') && !filename.endsWith('.se')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid file type. Please upload a .sie file' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Read file as ArrayBuffer for encoding detection
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const encoding = detectEncoding(arrayBuffer)
|
||||
|
||||
// Decode to string
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Check for duplicate import
|
||||
const duplicate = await checkDuplicateImport(user.id, content)
|
||||
if (duplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`,
|
||||
importId: duplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Validate the parsed data
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
// If there are critical errors, return them
|
||||
if (!validation.valid) {
|
||||
return NextResponse.json({
|
||||
error: 'validation',
|
||||
message: 'SIE file has validation errors',
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch user's chart of accounts
|
||||
const { data: basAccounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('is_active', true)
|
||||
.order('account_number')
|
||||
|
||||
if (!basAccounts || basAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
error: 'No chart of accounts found. Please complete onboarding first.',
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch stored mappings from database
|
||||
const { data: storedMappings } = await supabase
|
||||
.from('sie_account_mappings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
// Suggest account mappings
|
||||
const mappings = suggestMappings(
|
||||
parsed.accounts,
|
||||
basAccounts,
|
||||
(storedMappings as SIEAccountMappingRecord[]) || undefined
|
||||
)
|
||||
|
||||
// Generate preview
|
||||
const preview = generateImportPreview(parsed, mappings)
|
||||
|
||||
// Calculate file hash for storage
|
||||
const fileHash = await calculateFileHash(content)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
encoding,
|
||||
fileHash,
|
||||
parsed: {
|
||||
header: parsed.header,
|
||||
accounts: parsed.accounts,
|
||||
stats: parsed.stats,
|
||||
issues: parsed.issues,
|
||||
},
|
||||
mappings,
|
||||
mappingStats: getMappingStats(mappings),
|
||||
preview,
|
||||
validation: {
|
||||
valid: validation.valid,
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('SIE parse error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to parse SIE file' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/import/sie
|
||||
* List all SIE imports for the user
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get('limit') || '20', 10)
|
||||
const offset = parseInt(searchParams.get('offset') || '0', 10)
|
||||
const status = searchParams.get('status')
|
||||
|
||||
let query = supabase
|
||||
.from('sie_imports')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
count,
|
||||
limit,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch invoice with customer and items
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json({ error: 'Company settings not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
const items = (invoice.items as InvoiceItem[]).sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
// If this is a credit note, fetch the original invoice number
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PDF
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: invoice as Invoice,
|
||||
customer: invoice.customer as Customer,
|
||||
items,
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
})
|
||||
)
|
||||
|
||||
// Convert Node.js Buffer to Uint8Array for Response
|
||||
const uint8Array = new Uint8Array(pdfBuffer)
|
||||
|
||||
// Return PDF as response
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
|
||||
return new NextResponse(uint8Array, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Content-Length': pdfBuffer.length.toString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('PDF generation error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'PDF generation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { InvoicePDF } from '@/lib/invoice/pdf-template'
|
||||
import { sendEmail, isResendConfigured } from '@/lib/email/resend'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if Resend is configured
|
||||
if (!isResendConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'E-posttjänsten är inte konfigurerad. Kontakta support.' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch invoice with customer and items
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select(`
|
||||
*,
|
||||
customer:customers(*),
|
||||
items:invoice_items(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify customer has email
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch company settings
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Företagsinställningar saknas' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sort items by sort_order
|
||||
const items = (invoice.items as InvoiceItem[]).sort(
|
||||
(a, b) => a.sort_order - b.sort_order
|
||||
)
|
||||
|
||||
// If this is a credit note, fetch the original invoice number
|
||||
let originalInvoiceNumber: string | undefined
|
||||
if (invoice.credited_invoice_id) {
|
||||
const { data: originalInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('invoice_number')
|
||||
.eq('id', invoice.credited_invoice_id)
|
||||
.single()
|
||||
|
||||
if (originalInvoice) {
|
||||
originalInvoiceNumber = originalInvoice.invoice_number
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate PDF
|
||||
const pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: invoice as Invoice,
|
||||
customer,
|
||||
items,
|
||||
company: company as CompanySettings,
|
||||
originalInvoiceNumber,
|
||||
})
|
||||
)
|
||||
|
||||
// Prepare email data
|
||||
const emailData = {
|
||||
invoice: invoice as Invoice,
|
||||
customer,
|
||||
company: company as CompanySettings
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
const isCreditNote = !!invoice.credited_invoice_id
|
||||
const filename = isCreditNote
|
||||
? `kreditfaktura-${invoice.invoice_number}.pdf`
|
||||
: `faktura-${invoice.invoice_number}.pdf`
|
||||
|
||||
// Send email
|
||||
const result = await sendEmail({
|
||||
to: customer.email,
|
||||
subject: generateInvoiceEmailSubject(emailData),
|
||||
html: generateInvoiceEmailHtml(emailData),
|
||||
text: generateInvoiceEmailText(emailData),
|
||||
replyTo: (company as CompanySettings & { email?: string }).email || undefined,
|
||||
fromName: company.company_name,
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to send invoice email:', result.error)
|
||||
return NextResponse.json(
|
||||
{ error: `Kunde inte skicka e-post: ${result.error}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update invoice status to "sent" and set sent_at timestamp
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update invoice status:', updateError)
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Fakturan har skickats till ${customer.email}`,
|
||||
messageId: result.messageId
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Send invoice error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Kunde inte skicka fakturan' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// Create a service client (no auth needed - public endpoint with token validation)
|
||||
function createServiceClient() {
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return [] },
|
||||
setAll() { }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { token, action } = body
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!action || !['marked_paid', 'disputed'].includes(action)) {
|
||||
return NextResponse.json({ error: 'Ogiltig åtgärd' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Find the reminder by action token
|
||||
const { data: reminder, error: findError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.select(`
|
||||
*,
|
||||
invoice:invoices(
|
||||
id,
|
||||
invoice_number,
|
||||
status,
|
||||
user_id
|
||||
)
|
||||
`)
|
||||
.eq('action_token', token)
|
||||
.single()
|
||||
|
||||
if (findError || !reminder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltig eller utgången länk' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if token was already used
|
||||
if (reminder.action_token_used) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Denna länk har redan använts' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update the reminder with the response
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.update({
|
||||
response_type: action,
|
||||
response_at: new Date().toISOString(),
|
||||
action_token_used: true
|
||||
})
|
||||
.eq('id', reminder.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Failed to update reminder:', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte spara ditt svar' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// If customer marked as paid, we could optionally notify the business owner
|
||||
// For now, we just log it - the business owner will see it in the UI
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoiceData = reminder.invoice as any
|
||||
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
|
||||
console.log(`Customer responded to invoice ${invoice?.invoice_number}: ${action}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: action === 'marked_paid'
|
||||
? 'Tack! Vi har noterat att du har betalat fakturan.'
|
||||
: 'Tack! Vi har noterat din invändning och kommer att kontakta dig.'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Action handler error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Ett fel uppstod' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GET endpoint to fetch reminder/invoice info by token (for the public page)
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const token = url.searchParams.get('token')
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Token saknas' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Find the reminder by action token
|
||||
const { data: reminder, error: findError } = await supabase
|
||||
.from('invoice_reminders')
|
||||
.select(`
|
||||
id,
|
||||
reminder_level,
|
||||
sent_at,
|
||||
response_type,
|
||||
action_token_used,
|
||||
invoice:invoices(
|
||||
id,
|
||||
invoice_number,
|
||||
invoice_date,
|
||||
due_date,
|
||||
total,
|
||||
currency,
|
||||
status,
|
||||
customer:customers(
|
||||
name
|
||||
)
|
||||
)
|
||||
`)
|
||||
.eq('action_token', token)
|
||||
.single()
|
||||
|
||||
if (findError || !reminder) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Ogiltig eller utgången länk' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Don't expose sensitive data, just what's needed for the public page
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const invoiceData = reminder.invoice as any
|
||||
const invoice = Array.isArray(invoiceData) ? invoiceData[0] : invoiceData
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Faktura hittades inte' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Handle nested customer which may also be an array
|
||||
const customerData = invoice.customer
|
||||
const customer = Array.isArray(customerData) ? customerData[0] : customerData
|
||||
|
||||
return NextResponse.json({
|
||||
invoiceNumber: invoice.invoice_number,
|
||||
invoiceDate: invoice.invoice_date,
|
||||
dueDate: invoice.due_date,
|
||||
total: invoice.total,
|
||||
currency: invoice.currency,
|
||||
customerName: customer?.name,
|
||||
reminderLevel: reminder.reminder_level,
|
||||
alreadyResponded: reminder.action_token_used,
|
||||
previousResponse: reminder.response_type
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { processOverdueReminders } from '@/lib/invoices/reminder-processor'
|
||||
import { isResendConfigured } from '@/lib/email/resend'
|
||||
|
||||
// Verify cron secret for security
|
||||
function verifyCronSecret(request: Request): boolean {
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (!cronSecret) {
|
||||
console.error('CRON_SECRET not configured')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Support both "Bearer <token>" and just "<token>" formats
|
||||
const token = authHeader.startsWith('Bearer ')
|
||||
? authHeader.substring(7)
|
||||
: authHeader
|
||||
|
||||
return token === cronSecret
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron authentication
|
||||
if (!verifyCronSecret(request)) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if email service is configured
|
||||
if (!isResendConfigured()) {
|
||||
console.error('Resend not configured, skipping reminder cron')
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Email service not configured'
|
||||
}, { status: 503 })
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Starting invoice reminder cron job...')
|
||||
|
||||
const result = await processOverdueReminders()
|
||||
|
||||
console.log(`Reminder cron completed: ${result.sent} sent, ${result.failed} failed out of ${result.processed} processed`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
processed: result.processed,
|
||||
sent: result.sent,
|
||||
failed: result.failed,
|
||||
results: result.results.map(r => ({
|
||||
invoiceNumber: r.invoiceNumber,
|
||||
reminderLevel: r.reminderLevel,
|
||||
success: r.success,
|
||||
error: r.error
|
||||
}))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Invoice reminder cron job error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Cron job failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Also support POST for manual triggering via dashboard
|
||||
export async function POST(request: Request) {
|
||||
return GET(request)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateInvoiceInput, Invoice } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createInvoiceJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
|
||||
interface CreateCreditNoteInput {
|
||||
credited_invoice_id: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*)', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('invoice_date', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (status) {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Check if this is a credit note creation request
|
||||
if (body.credited_invoice_id) {
|
||||
return createCreditNote(supabase, user.id, body as CreateCreditNoteInput)
|
||||
}
|
||||
|
||||
const invoiceInput = body as CreateInvoiceInput
|
||||
|
||||
// Get customer for VAT calculation
|
||||
const { data: customer, error: customerError } = await supabase
|
||||
.from('customers')
|
||||
.select('*')
|
||||
.eq('id', invoiceInput.customer_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (customerError || !customer) {
|
||||
return NextResponse.json({ error: 'Customer not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Calculate VAT rules
|
||||
const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
|
||||
|
||||
// Calculate subtotal from items
|
||||
const subtotal = invoiceInput.items.reduce((sum, item) => {
|
||||
return sum + item.quantity * item.unit_price
|
||||
}, 0)
|
||||
|
||||
const vatAmount = calculateVat(subtotal, vatRules.rate)
|
||||
const total = subtotal + vatAmount
|
||||
|
||||
// Handle currency conversion
|
||||
let exchangeRate: number | null = null
|
||||
let exchangeRateDate: string | null = null
|
||||
let subtotalSek: number | null = null
|
||||
let vatAmountSek: number | null = null
|
||||
let totalSek: number | null = null
|
||||
|
||||
if (invoiceInput.currency !== 'SEK') {
|
||||
const rateData = await fetchExchangeRate(invoiceInput.currency)
|
||||
if (rateData) {
|
||||
exchangeRate = rateData.rate
|
||||
exchangeRateDate = rateData.date
|
||||
subtotalSek = convertToSEK(subtotal, exchangeRate)
|
||||
vatAmountSek = convertToSEK(vatAmount, exchangeRate)
|
||||
totalSek = convertToSEK(total, exchangeRate)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invoice number
|
||||
const { data: invoiceNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
customer_id: invoiceInput.customer_id,
|
||||
invoice_number: invoiceNumber,
|
||||
invoice_date: invoiceInput.invoice_date,
|
||||
due_date: invoiceInput.due_date,
|
||||
currency: invoiceInput.currency,
|
||||
exchange_rate: exchangeRate,
|
||||
exchange_rate_date: exchangeRateDate,
|
||||
subtotal,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: vatAmount,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total,
|
||||
total_sek: totalSek,
|
||||
vat_treatment: vatRules.treatment,
|
||||
vat_rate: vatRules.rate,
|
||||
moms_ruta: vatRules.momsRuta,
|
||||
reverse_charge_text: vatRules.reverseChargeText || null,
|
||||
your_reference: invoiceInput.your_reference,
|
||||
our_reference: invoiceInput.our_reference,
|
||||
notes: invoiceInput.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError) {
|
||||
return NextResponse.json({ error: invoiceError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create invoice items
|
||||
const items = invoiceInput.items.map((item, index) => ({
|
||||
invoice_id: invoice.id,
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.quantity * item.unit_price,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(items)
|
||||
|
||||
if (itemsError) {
|
||||
// Rollback invoice creation
|
||||
await supabase.from('invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch complete invoice with items
|
||||
const { data: completeInvoice } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Create journal entry for the invoice (non-blocking)
|
||||
if (completeInvoice) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
completeInvoice as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry:', err)
|
||||
// Don't fail the invoice creation
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeInvoice })
|
||||
}
|
||||
|
||||
// Create a credit note for an existing invoice
|
||||
async function createCreditNote(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
input: CreateCreditNoteInput
|
||||
) {
|
||||
// Fetch the original invoice with items
|
||||
const { data: originalInvoice, error: originalError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, items:invoice_items(*)')
|
||||
.eq('id', input.credited_invoice_id)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (originalError || !originalInvoice) {
|
||||
return NextResponse.json({ error: 'Original invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if invoice is already credited
|
||||
if (originalInvoice.status === 'credited') {
|
||||
return NextResponse.json({ error: 'Invoice has already been credited' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check if invoice can be credited (only sent, paid, or overdue invoices can be credited)
|
||||
if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only sent, paid, or overdue invoices can be credited' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Generate credit note number
|
||||
const creditNoteNumber = `KR-${originalInvoice.invoice_number}`
|
||||
|
||||
// Create the credit note with negated amounts
|
||||
const { data: creditNote, error: creditNoteError } = await supabase
|
||||
.from('invoices')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
customer_id: originalInvoice.customer_id,
|
||||
invoice_number: creditNoteNumber,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: new Date().toISOString().split('T')[0],
|
||||
currency: originalInvoice.currency,
|
||||
exchange_rate: originalInvoice.exchange_rate,
|
||||
exchange_rate_date: originalInvoice.exchange_rate_date,
|
||||
// Negate all amounts
|
||||
subtotal: -Math.abs(originalInvoice.subtotal),
|
||||
subtotal_sek: originalInvoice.subtotal_sek ? -Math.abs(originalInvoice.subtotal_sek) : null,
|
||||
vat_amount: -Math.abs(originalInvoice.vat_amount),
|
||||
vat_amount_sek: originalInvoice.vat_amount_sek ? -Math.abs(originalInvoice.vat_amount_sek) : null,
|
||||
total: -Math.abs(originalInvoice.total),
|
||||
total_sek: originalInvoice.total_sek ? -Math.abs(originalInvoice.total_sek) : null,
|
||||
// Same VAT treatment as original
|
||||
vat_treatment: originalInvoice.vat_treatment,
|
||||
vat_rate: originalInvoice.vat_rate,
|
||||
moms_ruta: originalInvoice.moms_ruta,
|
||||
reverse_charge_text: originalInvoice.reverse_charge_text,
|
||||
// References
|
||||
your_reference: originalInvoice.your_reference,
|
||||
our_reference: originalInvoice.our_reference,
|
||||
notes: input.reason || `Krediterar faktura ${originalInvoice.invoice_number}`,
|
||||
credited_invoice_id: input.credited_invoice_id,
|
||||
status: 'sent', // Credit notes are immediately "sent"
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (creditNoteError) {
|
||||
return NextResponse.json({ error: creditNoteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Create credit note items (negated from original)
|
||||
const creditNoteItems = (originalInvoice.items || []).map((item: { sort_order: number; description: string; quantity: number; unit: string; unit_price: number; line_total: number }, index: number) => ({
|
||||
invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: -Math.abs(item.quantity),
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: -Math.abs(item.line_total),
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('invoice_items')
|
||||
.insert(creditNoteItems)
|
||||
|
||||
if (itemsError) {
|
||||
// Rollback credit note creation
|
||||
await supabase.from('invoices').delete().eq('id', creditNote.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update original invoice status to 'credited'
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'credited' })
|
||||
.eq('id', input.credited_invoice_id)
|
||||
|
||||
// Fetch complete credit note with items
|
||||
const { data: completeCreditNote } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', creditNote.id)
|
||||
.single()
|
||||
|
||||
// Create journal entry for the credit note (non-blocking)
|
||||
if (completeCreditNote) {
|
||||
try {
|
||||
const journalEntry = await createCreditNoteJournalEntry(
|
||||
userId,
|
||||
completeCreditNote as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: completeCreditNote })
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
sendTaxDeadlineNotifications,
|
||||
sendInvoiceNotifications,
|
||||
sendCampaignNotifications,
|
||||
} from '@/lib/push/notification-scheduler'
|
||||
|
||||
/**
|
||||
* GET /api/push/cron
|
||||
* Daily cron job to send push notifications
|
||||
* Runs at 09:00 every day
|
||||
*
|
||||
* Vercel Cron: "0 9 * * *"
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Create a service role client for accessing all user data
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
try {
|
||||
// Send all notification types in parallel
|
||||
const [taxResult, invoiceResult, campaignResult] = await Promise.all([
|
||||
sendTaxDeadlineNotifications(supabase),
|
||||
sendInvoiceNotifications(supabase),
|
||||
sendCampaignNotifications(supabase),
|
||||
])
|
||||
|
||||
const totalSent =
|
||||
taxResult.sent + invoiceResult.sent + campaignResult.sent
|
||||
const totalSkipped =
|
||||
taxResult.skipped + invoiceResult.skipped + campaignResult.skipped
|
||||
|
||||
console.log(
|
||||
`Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Tax: ${taxResult.sent} sent, ${taxResult.skipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped`
|
||||
)
|
||||
console.log(
|
||||
` Campaign: ${campaignResult.sent} sent, ${campaignResult.skipped} skipped`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
totalSent,
|
||||
totalSkipped,
|
||||
details: {
|
||||
taxDeadlines: taxResult,
|
||||
invoices: invoiceResult,
|
||||
campaigns: campaignResult,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in push notification cron:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send push notifications' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getVapidPublicKey } from '@/lib/push/web-push'
|
||||
|
||||
/**
|
||||
* GET /api/push/subscribe
|
||||
* Get the VAPID public key for client-side subscription
|
||||
*/
|
||||
export async function GET() {
|
||||
const vapidKey = getVapidPublicKey()
|
||||
|
||||
if (!vapidKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Push notifications not configured' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ vapidPublicKey: vapidKey })
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/push/subscribe
|
||||
* Save a new push subscription
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { endpoint, keys } = body
|
||||
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid subscription data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get user agent for debugging
|
||||
const userAgent = request.headers.get('user-agent') || null
|
||||
|
||||
// Upsert subscription (update if endpoint exists)
|
||||
const { data, error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
user_agent: userAgent,
|
||||
is_active: true,
|
||||
last_used_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id,endpoint',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error saving subscription:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Also ensure notification settings exist with defaults
|
||||
await supabase
|
||||
.from('notification_settings')
|
||||
.upsert(
|
||||
{
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
campaign_deadlines_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id',
|
||||
ignoreDuplicates: true,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: data.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/push/subscribe
|
||||
* Remove a push subscription
|
||||
*/
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { endpoint } = body
|
||||
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endpoint is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.delete()
|
||||
.eq('user_id', user.id)
|
||||
.eq('endpoint', endpoint)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to remove subscription' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { ConfirmReceiptInput } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/confirm
|
||||
* Confirm line item classifications and optionally link to a transaction
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: fetchError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const body: ConfirmReceiptInput = await request.json()
|
||||
|
||||
// Update line items with classifications
|
||||
if (body.line_items && body.line_items.length > 0) {
|
||||
for (const item of body.line_items) {
|
||||
const { error: updateError } = await supabase
|
||||
.from('receipt_line_items')
|
||||
.update({
|
||||
is_business: item.is_business,
|
||||
category: item.category || null,
|
||||
bas_account: item.bas_account || null,
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('receipt_id', id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Line item update error:', updateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build receipt update
|
||||
const receiptUpdate: Record<string, unknown> = {
|
||||
status: 'confirmed',
|
||||
}
|
||||
|
||||
// Add restaurant representation data if provided
|
||||
if (body.representation_persons !== undefined) {
|
||||
receiptUpdate.representation_persons = body.representation_persons
|
||||
}
|
||||
if (body.representation_purpose !== undefined) {
|
||||
receiptUpdate.representation_purpose = body.representation_purpose
|
||||
}
|
||||
|
||||
// Link to transaction if provided
|
||||
if (body.matched_transaction_id) {
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', body.matched_transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!txError && transaction) {
|
||||
receiptUpdate.matched_transaction_id = body.matched_transaction_id
|
||||
|
||||
// Also update the transaction with the receipt link
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', body.matched_transaction_id)
|
||||
}
|
||||
}
|
||||
|
||||
// Update the receipt
|
||||
const { data: updatedReceipt, error: updateError } = await supabase
|
||||
.from('receipts')
|
||||
.update(receiptUpdate)
|
||||
.eq('id', id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('Receipt update error:', updateError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updatedReceipt })
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findTransactionMatches } from '@/lib/receipts/receipt-matcher'
|
||||
import type { Receipt, Transaction } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/receipts/[id]/match
|
||||
* Find potential transaction matches for a receipt
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch receipt
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get date range for transaction search (±7 days from receipt date)
|
||||
const receiptDate = receipt.receipt_date ? new Date(receipt.receipt_date) : new Date()
|
||||
const startDate = new Date(receiptDate)
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
const endDate = new Date(receiptDate)
|
||||
endDate.setDate(endDate.getDate() + 7)
|
||||
|
||||
// Fetch unmatched transactions in date range
|
||||
const { data: transactions, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.is('receipt_id', null)
|
||||
.lt('amount', 0) // Only expenses
|
||||
.gte('date', startDate.toISOString().split('T')[0])
|
||||
.lte('date', endDate.toISOString().split('T')[0])
|
||||
.order('date', { ascending: false })
|
||||
|
||||
if (txError) {
|
||||
console.error('Transaction fetch error:', txError)
|
||||
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Find matches
|
||||
const matches = findTransactionMatches(
|
||||
receipt as unknown as Receipt,
|
||||
transactions as Transaction[]
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
matches: matches.slice(0, 5), // Return top 5 matches
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]/match
|
||||
* Link a receipt to a specific transaction
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { transaction_id, match_confidence } = body
|
||||
|
||||
if (!transaction_id) {
|
||||
return NextResponse.json({ error: 'transaction_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction ownership
|
||||
const { data: transaction, error: txError } = await supabase
|
||||
.from('transactions')
|
||||
.select('id')
|
||||
.eq('id', transaction_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (txError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Update receipt with match
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: transaction_id,
|
||||
match_confidence: match_confidence || null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Update transaction with receipt link
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: id })
|
||||
.eq('id', transaction_id)
|
||||
|
||||
if (updateTxError) {
|
||||
console.error('Transaction update error:', updateTxError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
transaction_id,
|
||||
matched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]/match
|
||||
* Unlink a receipt from its matched transaction
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Verify receipt ownership and get current transaction link
|
||||
const { data: receipt, error: receiptError } = await supabase
|
||||
.from('receipts')
|
||||
.select('id, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (receiptError || !receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const transactionId = receipt.matched_transaction_id
|
||||
|
||||
// Remove match from receipt
|
||||
const { error: updateReceiptError } = await supabase
|
||||
.from('receipts')
|
||||
.update({
|
||||
matched_transaction_id: null,
|
||||
match_confidence: null,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateReceiptError) {
|
||||
console.error('Receipt update error:', updateReceiptError)
|
||||
return NextResponse.json({ error: 'Failed to update receipt' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Remove receipt link from transaction
|
||||
if (transactionId) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', transactionId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
receipt_id: id,
|
||||
unmatched: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/receipts/[id]
|
||||
* Get a single receipt with line items
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*),
|
||||
matched_transaction:transactions(*)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/receipts/[id]
|
||||
* Update a receipt
|
||||
*/
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
// Allowed update fields
|
||||
const allowedFields = [
|
||||
'merchant_name',
|
||||
'receipt_date',
|
||||
'receipt_time',
|
||||
'total_amount',
|
||||
'currency',
|
||||
'vat_amount',
|
||||
'is_restaurant',
|
||||
'is_systembolaget',
|
||||
'is_foreign_merchant',
|
||||
'representation_persons',
|
||||
'representation_purpose',
|
||||
'status',
|
||||
]
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
updates[field] = body[field]
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('receipts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select(`
|
||||
*,
|
||||
line_items:receipt_line_items(*)
|
||||
`)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/receipts/[id]
|
||||
* Delete a receipt and its line items
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get receipt to find image URL for cleanup
|
||||
const { data: receipt } = await supabase
|
||||
.from('receipts')
|
||||
.select('image_url, matched_transaction_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!receipt) {
|
||||
return NextResponse.json({ error: 'Receipt not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Unlink from transaction if matched
|
||||
if (receipt.matched_transaction_id) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ receipt_id: null })
|
||||
.eq('id', receipt.matched_transaction_id)
|
||||
}
|
||||
|
||||
// Delete receipt (line items are cascade deleted)
|
||||
const { error } = await supabase
|
||||
.from('receipts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Optionally delete image from storage
|
||||
if (receipt.image_url) {
|
||||
try {
|
||||
const urlParts = receipt.image_url.split('/receipts/')
|
||||
if (urlParts[1]) {
|
||||
await supabase.storage.from('receipts').remove([urlParts[1]])
|
||||
}
|
||||
} catch {
|
||||
// Ignore storage cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user