Initial commit
This commit is contained in:
80
frontend-client/src/lib/stores/app.ts
Normal file
80
frontend-client/src/lib/stores/app.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
tenant_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
categories: Category[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AppState = {
|
||||
categories: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create app store
|
||||
function createAppStore() {
|
||||
const { subscribe, set, update }: Writable<AppState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load categories
|
||||
loadCategories: async () => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const categories = await apiCall('/categories/');
|
||||
update(state => ({ ...state, categories, isLoading: false }));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load categories'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update(state => ({ ...state, error: null }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const app = createAppStore();
|
||||
139
frontend-client/src/lib/stores/auth.ts
Normal file
139
frontend-client/src/lib/stores/auth.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
tenant_id: string;
|
||||
role: 'CLIENT_ADMIN' | 'CLIENT_USER';
|
||||
is_active: boolean;
|
||||
is_two_factor_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
tenant_slug: string;
|
||||
totp_code?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
// Create auth store
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update }: Writable<AuthState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Initialize auth from localStorage
|
||||
init: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const user = localStorage.getItem('auth_user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(user);
|
||||
set({
|
||||
user: parsedUser,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored auth data:', error);
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Login
|
||||
login: async (credentials: LoginRequest): Promise<void> => {
|
||||
update(state => ({ ...state, isLoading: true }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data: LoginResponse = await response.json();
|
||||
|
||||
// Store auth data
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
localStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||
}
|
||||
|
||||
set({
|
||||
user: data.user,
|
||||
token: data.access_token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false
|
||||
});
|
||||
} catch (error) {
|
||||
update(state => ({ ...state, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_user');
|
||||
}
|
||||
set(initialState);
|
||||
},
|
||||
|
||||
// Update user data
|
||||
updateUser: (user: User) => {
|
||||
update(state => ({ ...state, user }));
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('auth_user', JSON.stringify(user));
|
||||
}
|
||||
},
|
||||
|
||||
// Set loading state
|
||||
setLoading: (isLoading: boolean) => {
|
||||
update(state => ({ ...state, isLoading }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
272
frontend-client/src/lib/stores/tickets.ts
Normal file
272
frontend-client/src/lib/stores/tickets.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { auth } from './auth.js';
|
||||
import { get } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Types
|
||||
export interface Ticket {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
category_id: string;
|
||||
category_name?: string;
|
||||
client_id: string;
|
||||
assigned_to_id: string | null;
|
||||
assigned_to_name?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
due_date: string | null;
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
export interface TicketComment {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_role: string;
|
||||
content: string;
|
||||
is_internal: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TicketAttachment {
|
||||
id: string;
|
||||
ticket_id: string;
|
||||
filename: string;
|
||||
original_filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
uploaded_by_id: string;
|
||||
uploaded_by_name: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
export interface CreateTicketRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
category_id: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
|
||||
}
|
||||
|
||||
export interface TicketsState {
|
||||
tickets: Ticket[];
|
||||
currentTicket: Ticket | null;
|
||||
comments: TicketComment[];
|
||||
attachments: TicketAttachment[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Initial state
|
||||
const initialState: TicketsState = {
|
||||
tickets: [],
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: [],
|
||||
isLoading: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
// API helper function
|
||||
async function apiCall(endpoint: string, options: RequestInit = {}) {
|
||||
const authState = get(auth);
|
||||
|
||||
const response = await fetch(`/api/v1${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authState.token}`,
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Request failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Create tickets store
|
||||
function createTicketsStore() {
|
||||
const { subscribe, set, update }: Writable<TicketsState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
// Load user's tickets
|
||||
loadTickets: async () => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const tickets = await apiCall('/tickets/');
|
||||
update(state => ({ ...state, tickets, isLoading: false }));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load tickets'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Load specific ticket with details
|
||||
loadTicket: async (ticketId: string) => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [ticket, comments, attachments] = await Promise.all([
|
||||
apiCall(`/tickets/${ticketId}`),
|
||||
apiCall(`/tickets/${ticketId}/comments`),
|
||||
apiCall(`/tickets/${ticketId}/attachments`)
|
||||
]);
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: ticket,
|
||||
comments,
|
||||
attachments,
|
||||
isLoading: false
|
||||
}));
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load ticket'
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
// Create new ticket
|
||||
createTicket: async (ticket: CreateTicketRequest) => {
|
||||
update(state => ({ ...state, isLoading: true, error: null }));
|
||||
|
||||
try {
|
||||
const newTicket = await apiCall('/tickets/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ticket)
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
tickets: [newTicket, ...state.tickets],
|
||||
isLoading: false
|
||||
}));
|
||||
|
||||
return newTicket;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create ticket'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Add comment to ticket
|
||||
addComment: async (ticketId: string, content: string) => {
|
||||
try {
|
||||
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
comments: [...state.comments, comment]
|
||||
}));
|
||||
|
||||
return comment;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to add comment'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Upload attachment
|
||||
uploadAttachment: async (ticketId: string, file: File) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const authState = get(auth);
|
||||
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authState.token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Upload failed');
|
||||
}
|
||||
|
||||
const attachment = await response.json();
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
attachments: [...state.attachments, attachment]
|
||||
}));
|
||||
|
||||
return attachment;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to upload attachment'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Close ticket (client can close their own tickets)
|
||||
closeTicket: async (ticketId: string, resolution?: string) => {
|
||||
try {
|
||||
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ resolution })
|
||||
});
|
||||
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
|
||||
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
|
||||
}));
|
||||
|
||||
return updatedTicket;
|
||||
} catch (error) {
|
||||
update(state => ({
|
||||
...state,
|
||||
error: error instanceof Error ? error.message : 'Failed to close ticket'
|
||||
}));
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Clear error
|
||||
clearError: () => {
|
||||
update(state => ({ ...state, error: null }));
|
||||
},
|
||||
|
||||
// Clear current ticket
|
||||
clearCurrentTicket: () => {
|
||||
update(state => ({
|
||||
...state,
|
||||
currentTicket: null,
|
||||
comments: [],
|
||||
attachments: []
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const tickets = createTicketsStore();
|
||||
75
frontend-client/src/lib/stores/toast.ts
Normal file
75
frontend-client/src/lib/stores/toast.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Toast notification store
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: ToastMessage[];
|
||||
}
|
||||
|
||||
const initialState: ToastState = {
|
||||
toasts: []
|
||||
};
|
||||
|
||||
function createToastStore() {
|
||||
const { subscribe, update }: Writable<ToastState> = writable(initialState);
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
show: (type: ToastMessage['type'], message: string, duration = 5000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9);
|
||||
const toast: ToastMessage = { id, type, message, duration };
|
||||
|
||||
update(state => ({
|
||||
toasts: [...state.toasts, toast]
|
||||
}));
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
dismiss: (id: string) => {
|
||||
update(state => ({
|
||||
toasts: state.toasts.filter(t => t.id !== id)
|
||||
}));
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
update(() => initialState);
|
||||
},
|
||||
|
||||
// Convenience methods
|
||||
success: (message: string, duration?: number) => {
|
||||
return createToastStore().show('success', message, duration);
|
||||
},
|
||||
|
||||
error: (message: string, duration?: number) => {
|
||||
return createToastStore().show('error', message, duration);
|
||||
},
|
||||
|
||||
warning: (message: string, duration?: number) => {
|
||||
return createToastStore().show('warning', message, duration);
|
||||
},
|
||||
|
||||
info: (message: string, duration?: number) => {
|
||||
return createToastStore().show('info', message, duration);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const toast = createToastStore();
|
||||
Reference in New Issue
Block a user