Initial copy from influencer-biz

This commit is contained in:
Jakob Wennberg
2026-02-13 14:00:46 +01:00
commit a25d10a528
391 changed files with 96224 additions and 0 deletions
+480
View File
@@ -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
+600
View File
@@ -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
View File
@@ -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
+36
View File
@@ -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.
+35
View File
@@ -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`)
}
+144
View File
@@ -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 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>
)
}
+333
View File
@@ -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 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 dina influencer-kampanjer baserat 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>
)
}
+42
View File
@@ -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>
)
}
+142
View File
@@ -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>
)
}
+103
View File
@@ -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()
}}
/>
</>
)
}
+40
View File
@@ -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>
)
}
+351
View File
@@ -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 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>
)
}
+85
View File
@@ -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>
)
}
+445
View File
@@ -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>
)
}
+217
View File
@@ -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>
)
}
+379
View File
@@ -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 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>
)
}
+366
View File
@@ -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>
)
}
+428
View File
@@ -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 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 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>
)
}
+371
View File
@@ -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 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 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>
)
}
+736
View File
@@ -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>
)
}
+474
View File
@@ -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 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>
)
}
+292
View File
@@ -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 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 &quot;{searchTerm}&quot;
</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>
)
}
+53
View File
@@ -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>
)
}
+407
View File
@@ -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}
/>
)
}
+369
View File
@@ -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>
)
}
+157
View File
@@ -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
+830
View File
@@ -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 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 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>
)
}
+140
View File
@@ -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>
)
}
+237
View File
@@ -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>
)
}
+687
View File
@@ -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>
)
}
+420
View File
@@ -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>
)
}
+151
View File
@@ -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')}`
)
}
}
+92
View File
@@ -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 }
)
}
}
+176
View File
@@ -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,
})
}
+86
View File
@@ -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 })
}
+72
View File
@@ -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 })
}
+65
View File
@@ -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 })
}
+159
View File
@@ -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 })
}
+57
View File
@@ -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 }
)
}
}
+136
View File
@@ -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 })
}
}
+178
View File
@@ -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}`,
},
})
}
+146
View File
@@ -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 })
}
}
+185
View File
@@ -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
})
}
+204
View File
@@ -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 })
}
+321
View File
@@ -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 })
}
}
+173
View File
@@ -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 })
}
+144
View File
@@ -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
}
})
}
+169
View File
@@ -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 }
)
}
}
+146
View File
@@ -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 })
}
+84
View File
@@ -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 }
)
}
}
+195
View File
@@ -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' } }
)
}
}
+55
View File
@@ -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
})
}
+244
View File
@@ -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 || []
}
+163
View File
@@ -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 })
}
+133
View File
@@ -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 })
}
+64
View File
@@ -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 })
}
+56
View File
@@ -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 })
}
+133
View File
@@ -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 })
}
+105
View File
@@ -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,
})
}
+109
View File
@@ -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 })
}
+55
View File
@@ -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 }
)
}
}
+153
View File
@@ -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 })
}
+126
View File
@@ -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 })
}
+145
View File
@@ -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 })
}
+102
View File
@@ -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
}
})
}
+168
View File
@@ -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 })
}
+51
View File
@@ -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 }
)
}
}
+170
View File
@@ -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 })
}
+81
View File
@@ -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 })
}
+71
View File
@@ -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 })
}
+150
View File
@@ -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 }
)
}
}
+130
View File
@@ -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 }
)
}
}
+155
View File
@@ -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 })
}
+139
View File
@@ -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 }
)
}
}
+48
View File
@@ -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,
})
}
+100
View File
@@ -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 }
)
}
}
+169
View File
@@ -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 }
)
}
}
+173
View File
@@ -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
})
}
+73
View File
@@ -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)
}
+322
View File
@@ -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 })
}
+81
View File
@@ -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 }
)
}
}
+136
View File
@@ -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 })
}
+108
View File
@@ -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 })
}
+218
View File
@@ -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,
},
})
}
+176
View File
@@ -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